diff --git a/android/app/build.gradle b/android/app/build.gradle index 8263bcb5..ba2e7faa 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -20,12 +20,12 @@ if (project.hasProperty('google-services.json')) { def flutterVersionCode = localProperties.getProperty("flutter.versionCode") if (flutterVersionCode == null) { - flutterVersionCode = "26" + flutterVersionCode = "34" } def flutterVersionName = localProperties.getProperty("flutter.versionName") if (flutterVersionName == null) { - flutterVersionName = "1.2.18" + flutterVersionName = "1.2.26" } def keystorePropertiesFile = rootProject.file("key.properties") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2555821c..b25d85f3 100755 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -52,6 +52,7 @@ android:host="resume_authn" /> + @@ -114,6 +115,22 @@ + + + + + + + + + + + + + + + + diff --git a/assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Active_dark.png b/assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Active_dark.png new file mode 100644 index 00000000..a9efe6fb Binary files /dev/null and b/assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Active_dark.png differ diff --git a/assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Active_light.png b/assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Active_light.png new file mode 100644 index 00000000..a9efe6fb Binary files /dev/null and b/assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Active_light.png differ diff --git a/assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Outline_Active.svg b/assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Outline_Active.svg new file mode 100644 index 00000000..d5cd8b48 --- /dev/null +++ b/assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Outline_Active.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/uae_pass_button/AR_UAEPASS_Sign_up_Btn_Outline_Active.png b/assets/uae_pass_button/AR_UAEPASS_Sign_up_Btn_Outline_Active.png new file mode 100644 index 00000000..0efa4207 Binary files /dev/null and b/assets/uae_pass_button/AR_UAEPASS_Sign_up_Btn_Outline_Active.png differ diff --git a/assets/uae_pass_button/AR_UAEPASS_Sign_up_Btn_Outline_Active.svg b/assets/uae_pass_button/AR_UAEPASS_Sign_up_Btn_Outline_Active.svg new file mode 100644 index 00000000..7160aff4 --- /dev/null +++ b/assets/uae_pass_button/AR_UAEPASS_Sign_up_Btn_Outline_Active.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/uae_pass_button/UAEPASS_Sign_in_Btn_Outline_Active.svg b/assets/uae_pass_button/UAEPASS_Sign_in_Btn_Outline_Active.svg new file mode 100644 index 00000000..1e4c2679 --- /dev/null +++ b/assets/uae_pass_button/UAEPASS_Sign_in_Btn_Outline_Active.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/uae_pass_button/UAEPASS_Sign_in_Btn_Outline_Active_dark.png b/assets/uae_pass_button/UAEPASS_Sign_in_Btn_Outline_Active_dark.png new file mode 100644 index 00000000..0dfb83d1 Binary files /dev/null and b/assets/uae_pass_button/UAEPASS_Sign_in_Btn_Outline_Active_dark.png differ diff --git a/assets/uae_pass_button/UAEPASS_Sign_in_Btn_Outline_Active_light.png b/assets/uae_pass_button/UAEPASS_Sign_in_Btn_Outline_Active_light.png new file mode 100644 index 00000000..0dfb83d1 Binary files /dev/null and b/assets/uae_pass_button/UAEPASS_Sign_in_Btn_Outline_Active_light.png differ diff --git a/assets/uae_pass_button/UAEPASS_Sign_up_Btn_Outline_Active.png b/assets/uae_pass_button/UAEPASS_Sign_up_Btn_Outline_Active.png new file mode 100644 index 00000000..50b3b8dd Binary files /dev/null and b/assets/uae_pass_button/UAEPASS_Sign_up_Btn_Outline_Active.png differ diff --git a/assets/uae_pass_button/UAEPASS_Sign_up_Btn_Outline_Active.svg b/assets/uae_pass_button/UAEPASS_Sign_up_Btn_Outline_Active.svg new file mode 100644 index 00000000..0555c1c0 --- /dev/null +++ b/assets/uae_pass_button/UAEPASS_Sign_up_Btn_Outline_Active.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 66843ffb..a767ead2 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -513,7 +513,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.16; + MARKETING_VERSION = 1.2.24; PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -707,7 +707,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.16; + MARKETING_VERSION = 1.2.24; PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -737,7 +737,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.16; + MARKETING_VERSION = 1.2.24; PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 62666446..ac340228 100755 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -10,4 +10,20 @@ import UIKit GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } -} + + // Called by iOS when UAE PASS app redirects back to your app scheme. + // aegovfcscstats://resume_authn?url= + override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + // Post to Flutter so the WebView can load the original successURL (step 7). + NotificationCenter.default.post( + name: NSNotification.Name("UaePassCallback"), + object: url.absoluteString + ) + // Always call super — lets other plugins (Firebase, etc.) handle their own schemes. + return super.application(app, open: url, options: options) + } +} \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index ff8561e2..7c2fe070 100755 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -35,7 +35,7 @@ ae.gov.fcsc.frontend.ios CFBundleURLSchemes - ae.gov.fcsc.frontend.ios + aegovfcscfrontendios @@ -44,7 +44,7 @@ ae.gov.fcsc.stats CFBundleURLSchemes - ae.gov.fcsc.stats + aegovfcscstats CFBundleRole Editor diff --git a/lib/config/api_config.dart b/lib/config/api_config.dart index c32efc27..b65b8c59 100755 --- a/lib/config/api_config.dart +++ b/lib/config/api_config.dart @@ -3,3 +3,9 @@ // const String apiUrl = 'https://pbdev.venbait.in/'; const String apiUrl = 'https://pb.venbait.in/'; // const String apiUrl = 'https://pocket.fcsc.gov.ae/'; + +/// When `true`, UAE PASS deep links open the **staging** app (`uaepassstg://`). +/// When `false`, the scheme from the IdP is preserved (`uaepass://` for production). +/// Set to `true` if PocketBase OIDC / UAE PASS IdP is configured for staging +/// (e.g. `stg-id.uaepass.ae` / `stg-ids.uaepass.ae`). +const bool uaePassUseStagingEnvironment = true; diff --git a/lib/config/my_router.dart b/lib/config/my_router.dart index ba116d32..297b490b 100755 --- a/lib/config/my_router.dart +++ b/lib/config/my_router.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:go_router/go_router.dart'; import 'package:uae_stat/config/sessionCheckScreen.dart'; @@ -12,7 +13,6 @@ import 'package:uae_stat/presentation/Screens/auth_verification/registration.dar import 'package:uae_stat/presentation/Screens/auth_verification/terms&conditions.dart'; import 'package:uae_stat/presentation/Screens/charts/screens/chart_screen.dart'; import 'package:uae_stat/presentation/Screens/online_offline_verification/internet_check.dart'; -import 'package:uae_stat/presentation/routes/auth_routes/checkingUAEPass.dart'; import 'package:uae_stat/presentation/routes/auth_routes/fcsc_profile_linking.dart'; import 'package:uae_stat/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/Detailedcompetitiveness.dart'; import 'package:uae_stat/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/competitiveness.dart'; @@ -37,7 +37,6 @@ import 'package:uae_stat/presentation/routes/drawer_routes/Drawer%20Items/user_g import '../presentation/Screens/auth_verification/create_new_pw.dart'; import '../presentation/Screens/profilepage.dart'; import '../presentation/routes/auth_routes/login_route.dart'; -import '../presentation/routes/auth_routes/uae_pass_resume_auth_screen.dart'; import 'package:uae_stat/presentation/routes/drawer_routes/Drawer%20Items/bookmark.dart'; import '../presentation/routes/drawer_routes/Drawer Items/edit_profile.dart'; import '../presentation/routes/drawer_routes/Drawer Items/feedback.dart'; @@ -114,13 +113,6 @@ final GoRouter router = GoRouter( // path: '/register', // builder: (context, state) => RegisterScreen(), // ), - GoRoute( - path: '/resume_authn', - builder: (context, state) { - final urlToLoad = state.uri.queryParameters['url']; - return UaePassResumeAuthScreen(urlToLoad: urlToLoad); - }, - ), GoRoute( path: '/fcscprofilelinking', builder: (context, state) { @@ -140,10 +132,7 @@ final GoRouter router = GoRouter( path: '/myhomepage', builder: (context, state) => MyHomePage(), ), - GoRoute( - path: '/checkingUAEPass', - builder: (context, state) => checkingUAEPass(), - ), + // GoRoute( // path: '/DemoHome/:dataSets', @@ -417,6 +406,10 @@ final GoRouter router = GoRouter( path: '/contact', builder: (context, state) => Contact(), ), + GoRoute( + path: '/faq', + builder: (context, state) => FAQPage(), + ), ], redirect: (context, state) async { // If UAE PASS redirected here with ?code= diff --git a/lib/config/uae_pass_oauth_errors.dart b/lib/config/uae_pass_oauth_errors.dart new file mode 100644 index 00000000..fe15d352 --- /dev/null +++ b/lib/config/uae_pass_oauth_errors.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; + +/// UAE PASS reference: Authentication vs Digital Signature cancellation errors. +enum UaePassOAuthFlowKind { + authentication, + digitalSignature, +} + +/// Returned when IdP redirects with `error=` instead of `code` on PocketBase OAuth redirect. +class UaePassOAuthRedirectException implements Exception { + UaePassOAuthRedirectException( + this.errorCode, { + this.errorDescription, + this.flowKind = UaePassOAuthFlowKind.authentication, + }); + + final String errorCode; + final String? errorDescription; + final UaePassOAuthFlowKind flowKind; + + @override + String toString() => + 'UaePassOAuthRedirectException($errorCode, flow=$flowKind, desc=$errorDescription)'; +} + +/// Authentication cancellations (UAE PASS table — login flow). +const _loginCancelledCodes = { + 'invalid_request', + 'login_required', + 'access_denied', + 'cancelledonapp', +}; + +/// Digital signature cancellations (subset per UAE PASS table). +const _signingCancelledCodes = { + 'invalid_request', + 'login_required', + 'access_denied', +}; + +bool uaePassIsUserCancelledLogin(String code) => + _loginCancelledCodes.contains(code.toLowerCase()); + +bool uaePassIsUserCancelledSigning(String code) => + _signingCancelledCodes.contains(code.toLowerCase()); + +/// Best-effort parse when PocketBase wraps OAuth errors inside JSON `response`. +String? uaePassTryExtractOAuthErrorCodeFromResponse( + Map response, +) { + try { + final blob = jsonEncode(response).toLowerCase(); + final ordered = { + ..._loginCancelledCodes, + ..._signingCancelledCodes, + }.toList() + ..sort((a, b) => b.length.compareTo(a.length)); + for (final c in ordered) { + if (blob.contains(c)) return c; + } + } catch (_) {} + return null; +} diff --git a/lib/config/uae_pass_oauth_state.dart b/lib/config/uae_pass_oauth_state.dart index d55c7df2..59edc604 100644 --- a/lib/config/uae_pass_oauth_state.dart +++ b/lib/config/uae_pass_oauth_state.dart @@ -11,6 +11,8 @@ class UaePassOAuthState { String? codeVerifier; String? redirectUrl; String? providerName; + String? oauthCode; + String? oauthState; /// BCP47 tag passed as OIDC `ui_locales` (e.g. `ar`, `en`) so UAE PASS login /// UI matches the language the user chose in the app. Read after auth to set @@ -39,6 +41,8 @@ class UaePassOAuthState { codeVerifier = null; redirectUrl = null; providerName = null; + oauthCode = null; + oauthState = null; pendingUiLocales = null; } diff --git a/lib/infrastructure/services/img_asset_paths/icons/misc_icon_asset_path.dart b/lib/infrastructure/services/img_asset_paths/icons/misc_icon_asset_path.dart index 97515c58..ff5f7c1c 100755 --- a/lib/infrastructure/services/img_asset_paths/icons/misc_icon_asset_path.dart +++ b/lib/infrastructure/services/img_asset_paths/icons/misc_icon_asset_path.dart @@ -1,10 +1,27 @@ abstract class MiscIconAssetPath { static const _basePath = 'assets/icons/misc'; + static const _uaePassButtonBasePath = 'assets/uae_pass_button'; static const rightChevronInSemiCircle = '$_basePath/right_chevron_in_semicircle.png'; static const signInWithGoogle = '$_basePath/signInWithGoogle.png'; static const signInWithApple = '$_basePath/signInWithApple.png'; - static const signInWithUAEPass = '$_basePath/signInWithUAEPass.png'; + // static const signInWithUAEPass = '$_basePath/signInWithUAEPass.png'; + static const signInWithUAEPassEnDark = + '$_uaePassButtonBasePath/UAEPASS_Sign_in_Btn_Outline_Active.svg'; + static const signInWithUAEPassArDark = + '$_uaePassButtonBasePath/AR_UAEPASS_Sign_in_Btn_Outline_Active.svg'; + static const signInWithUAEPassEnLight = + '$_uaePassButtonBasePath/UAEPASS_Sign_in_Btn_Outline_Active.svg'; + static const signInWithUAEPassArLight = + '$_uaePassButtonBasePath/AR_UAEPASS_Sign_in_Btn_Outline_Active.svg'; + static const signUpWithUAEPassEnDark = + '$_uaePassButtonBasePath/UAEPASS_Sign_up_Btn_Outline_Active.svg'; + static const signUpWithUAEPassArDark = + '$_uaePassButtonBasePath/AR_UAEPASS_Sign_up_Btn_Outline_Active.svg'; + static const signUpWithUAEPassEnLight = + '$_uaePassButtonBasePath/UAEPASS_Sign_up_Btn_Outline_Active.svg'; + static const signUpWithUAEPassArLight = + '$_uaePassButtonBasePath/AR_UAEPASS_Sign_up_Btn_Outline_Active.svg'; static const lock = '$_basePath/lock.png'; static const lockDark = '$_basePath/lockDark.png'; static const lockLight = '$_basePath/lockLight.png'; diff --git a/lib/infrastructure/services/pocketbase_service.dart b/lib/infrastructure/services/pocketbase_service.dart index 017137df..dbe85c33 100755 --- a/lib/infrastructure/services/pocketbase_service.dart +++ b/lib/infrastructure/services/pocketbase_service.dart @@ -8,6 +8,10 @@ abstract class PocketBaseService { // static const _host = 'https://pb.venbait.in'; // static const _host = 'http://127.0.0.1:8090'; static final _pb = PocketBase(_host); + + /// Single shared client — OAuth/SSE state must match [authStore] everywhere. + static PocketBase get client => _pb; + static final users = _pb.collection('users'); static final authStore = _pb.authStore; } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 786f8d23..58d07b0f 100755 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -518,6 +518,18 @@ abstract class AppLocalizations { /// **'Rank'** String get competitiveness_rank; + /// No description provided for @competitiveness_score_in. + /// + /// In en, this message translates to: + /// **'Score in'** + String get competitiveness_score_in; + + /// No description provided for @competitiveness_score. + /// + /// In en, this message translates to: + /// **'Score'** + String get competitiveness_score; + /// No description provided for @competitiveness_previous_edition. /// /// In en, this message translates to: @@ -551,7 +563,7 @@ abstract class AppLocalizations { /// No description provided for @competitiveness_uae_bilateral_trade. /// /// In en, this message translates to: - /// **'UAE Bilateral Trade (2022)'** + /// **'UAE Bilateral Trade'** String get competitiveness_uae_bilateral_trade; /// No description provided for @competitiveness_total. diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 3b93a091..113c4b78 100755 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -223,6 +223,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String get competitiveness_rank => 'رتبة'; + @override + String get competitiveness_score_in => 'سجل في'; + + @override + String get competitiveness_score => 'نتيجة'; + @override String get competitiveness_previous_edition => 'مقابل الإصدار السابق'; @@ -242,7 +248,7 @@ class AppLocalizationsAr extends AppLocalizations { @override String get competitiveness_uae_bilateral_trade => - 'التجارة الثنائية لدولة الإمارات العربية المتحدة (2022)'; + 'التجارة الثنائية لدولة الإمارات العربية المتحدة'; @override String get competitiveness_total => 'المجموع '; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d7c41ee5..efebcd14 100755 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -223,6 +223,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get competitiveness_rank => 'Rank'; + @override + String get competitiveness_score_in => 'Score in'; + + @override + String get competitiveness_score => 'Score'; + @override String get competitiveness_previous_edition => 'vs. Previous Edition'; @@ -240,8 +246,7 @@ class AppLocalizationsEn extends AppLocalizations { String get competitiveness_multi_countries => 'MULTIPLE COUNTRIES'; @override - String get competitiveness_uae_bilateral_trade => - 'UAE Bilateral Trade (2022)'; + String get competitiveness_uae_bilateral_trade => 'UAE Bilateral Trade'; @override String get competitiveness_total => 'Total '; diff --git a/lib/l10n/intl_ar.arb b/lib/l10n/intl_ar.arb index 7e2db5aa..2e4c90a7 100755 --- a/lib/l10n/intl_ar.arb +++ b/lib/l10n/intl_ar.arb @@ -78,12 +78,14 @@ "competitiveness_heading": "التقارير التنافسية", "competitiveness_rank_in": "الترتيب في", "competitiveness_rank": "رتبة", + "competitiveness_score_in": "سجل في", + "competitiveness_score": "نتيجة", "competitiveness_previous_edition": "مقابل الإصدار السابق", "competitiveness_first_globally": "أولا عالميا", "competitiveness_first_in_GCC": "الأول في دول مجلس التعاون الخليجي", "competitiveness_first_in_arab_countries": "الأول في دول مجلس التعاون الخليجي", "competitiveness_multi_countries": "دول متعددة", - "competitiveness_uae_bilateral_trade":"التجارة الثنائية لدولة الإمارات العربية المتحدة (2022)", + "competitiveness_uae_bilateral_trade":"التجارة الثنائية لدولة الإمارات العربية المتحدة", "competitiveness_total": "المجموع ", "country_profile": "ملف تعريف الدولة", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 7d2d9581..4ccce4c2 100755 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -76,12 +76,14 @@ "competitiveness_heading": "Competitive Reports", "competitiveness_rank_in": "Rank in", "competitiveness_rank": "Rank", + "competitiveness_score_in": "Score in", + "competitiveness_score": "Score", "competitiveness_previous_edition": "vs. Previous Edition", "competitiveness_first_globally": "First Globally", "competitiveness_first_in_GCC": "First in GCC", "competitiveness_first_in_arab_countries": "First in Arab Countries", "competitiveness_multi_countries": "MULTIPLE COUNTRIES", - "competitiveness_uae_bilateral_trade": "UAE Bilateral Trade (2022)", + "competitiveness_uae_bilateral_trade": "UAE Bilateral Trade", "competitiveness_total": "Total ", "country_profile": "Country Profile", diff --git a/lib/presentation/Screens/auth_verification/registration.dart b/lib/presentation/Screens/auth_verification/registration.dart index 22cfb006..f364d6f6 100755 --- a/lib/presentation/Screens/auth_verification/registration.dart +++ b/lib/presentation/Screens/auth_verification/registration.dart @@ -6,6 +6,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart' as http; import 'package:pocketbase/pocketbase.dart'; @@ -27,8 +28,9 @@ import 'package:uae_stat/presentation/components/dialogs.dart'; import 'package:uae_stat/presentation/components/indicators/locale_provider.dart'; import 'package:uae_stat/presentation/components/my_toggle.dart'; +import 'package:uae_stat/presentation/routes/auth_routes/oauth_webview.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../routes/auth_routes/login_route.dart'; +import '../../routes/auth_routes/login_route.dart' hide OAuthWebView; class RegisterScreen extends ConsumerStatefulWidget { final String? fromPage; @@ -316,7 +318,7 @@ class _RegisterScreenState extends ConsumerState { 'role': 'user', // 'status': 'Approved', // 'user_mail_verify': true, - 'emiratesid': uuid, + 'uuid': uuid, 'is_oauth_login': 1, }, headers: { @@ -538,7 +540,7 @@ class _RegisterScreenState extends ConsumerState { final existing = await pb.collection('users').getList( page: 1, perPage: 1, - filter: 'emiratesid = "$uuid"', + filter: 'uuid = "$uuid"', ); if (existing.items.isEmpty) { // First, persist a valid session so protected routes (like /profile) @@ -844,6 +846,15 @@ class _RegisterScreenState extends ConsumerState { double screenwidth = MediaQuery.of(context).size.width; final registerLocale = ref.watch(localeProvider); cameFromLinking = widget.fromPage == 'fcscprofilelinking'; + + if (cameFromLinking){ + setState(() { + _emailController.text = widget.userInfo?['email']?.toString().trim() ?? ''; + _usernameController.text = widget.userInfo?['firstnameEN']!.toString().trim() ?? ''; + }); + } + + ref.listen>(connectivityProvider, (previous, hasInternet) { if (hasInternet.value == false) { @@ -863,6 +874,32 @@ class _RegisterScreenState extends ConsumerState { height: 40, ); + final uaePassSignUpButtonAsset = registerLocale?.languageCode == 'ar' + ? (isDarkTheme + ? MiscIconAssetPath.signUpWithUAEPassArDark + : MiscIconAssetPath.signUpWithUAEPassArLight) + : (isDarkTheme + ? MiscIconAssetPath.signUpWithUAEPassEnDark + : MiscIconAssetPath.signUpWithUAEPassEnLight); + final signUpWithUAEPassBtn = Semantics( + button: true, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () async { + await oAuthGoogleAndAppleLogin(context, ref, 'oidc'); + }, + child: SvgPicture.asset( + uaePassSignUpButtonAsset, + width: double.infinity, + fit: BoxFit.fitWidth, + excludeFromSemantics: true, + ), + ), + ), + ); + Widget buildIconContainer(IconData icon, Color iconColor) { return Container( width: 60, @@ -1978,111 +2015,64 @@ class _RegisterScreenState extends ConsumerState { ), ), SizedBox(height: 10), - - Padding( - padding: const EdgeInsets.only( - top: 5.0, - bottom: 0, - left: 30, - right: 30), - child: SizedBox( - width: double.infinity, - child: Row( - children: [ - // Left line - Expanded( - child: Divider( - color: isDarkTheme - ? Colors.white - : Colors.grey.shade400, - thickness: 1, - endIndent: 10, - ), - ), - - // Center text - Text( - AppLocalizations.of(context)!.or, - style: TextStyle( - color: isDarkTheme - ? Colors.white - : Color(0x33000000), - fontWeight: FontWeight.w600, - fontSize: 16, - ), - ), - - // Right line - Expanded( - child: Divider( - color: isDarkTheme - ? Colors.white - : Colors.grey.shade400, - thickness: 1, - indent: 10, - ), - ), - ], - ), - ), - ), - SizedBox(height: 10), - Padding( + if (!cameFromLinking) ...[ + Padding( padding: const EdgeInsets.only( top: 5.0, bottom: 0, left: 30, right: 30), child: SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: () async { - await oAuthGoogleAndAppleLogin(context, ref, 'oidc'); - }, - style: OutlinedButton.styleFrom( - side: const BorderSide( - color: Color(0xFF000000)), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(8), - ), - padding: - const EdgeInsets.symmetric( - vertical: 16.5), - backgroundColor: Colors.white, - foregroundColor: Colors.black, - textStyle: TextStyle( - fontFamily: context.translate( - 'Roboto', 'NotoKufi'), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), + width: double.infinity, child: Row( - mainAxisAlignment: - MainAxisAlignment.center, children: [ - Image.asset( - MiscIconAssetPath - .signInWithUAEPass, // replace with your Google icon asset - height: 20, + // Left line + Expanded( + child: Divider( + color: isDarkTheme + ? Colors.white + : Colors.grey.shade400, + thickness: 1, + endIndent: 10, + ), ), - const SizedBox(width: 10), + + // Center text Text( - AppLocalizations.of(context)!.uaepass_sign_in, - textAlign: TextAlign.center, + AppLocalizations.of(context)!.or, style: TextStyle( - color: Color(0xFF000000), - fontWeight: - FontWeight.w600, + color: isDarkTheme + ? Colors.white + : Color(0x33000000), + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + + // Right line + Expanded( + child: Divider( + color: isDarkTheme + ? Colors.white + : Colors.grey.shade400, + thickness: 1, + indent: 10, ), ), ], ), ), - )), - - if (!cameFromLinking) ...[ + ), + SizedBox(height: 10), + Padding( + padding: const EdgeInsets.only( + top: 5.0, + bottom: 0, + left: 30, + right: 30, + ), + child: signUpWithUAEPassBtn, + ), SizedBox(height: 20), Text( AppLocalizations.of(context)! diff --git a/lib/presentation/Screens/profilepage.dart b/lib/presentation/Screens/profilepage.dart index 39de3ccd..5e728841 100755 --- a/lib/presentation/Screens/profilepage.dart +++ b/lib/presentation/Screens/profilepage.dart @@ -177,6 +177,7 @@ class _ProfileScreenState extends ConsumerState { setState(() { _usernameController.text = userDetailsResponse.data['uname'] ?? ''; _emailController.text = userDetailsResponse.data['email'] ?? ''; + _fullNameController.text = userDetailsResponse.data['full_name'] ?? ''; role = userDetailsResponse.data['role'] ?? ''; isOAuthLogin = userDetailsResponse.data['is_oauth_login'] ?? ''; }); diff --git a/lib/presentation/routes/auth_routes/checkingUAEPass.dart b/lib/presentation/routes/auth_routes/checkingUAEPass.dart deleted file mode 100755 index 39fc37a6..00000000 --- a/lib/presentation/routes/auth_routes/checkingUAEPass.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_uae_pass/flutter_uae_pass.dart'; - -class checkingUAEPass extends StatefulWidget { - const checkingUAEPass({super.key}); - - @override - State createState() => _checkingUAEPassState(); -} - -class _checkingUAEPassState extends State { - String? authCode; - String? accessToken; - ProfileData? profileData; - - final _uaePass = UaePass(); - - @override - void initState() { - super.initState(); - // _uaePass.setUpSandbox(); // This uses staging - correct for your staging app - _uaePass.setUpEnvironment( - clientId: "sandbox_stage", // from UAE PASS console - clientSecret: "sandbox_stage", // from UAE PASS console - isProduction: false, // staging environment - urlScheme: "fcscUaeStats", // 👈 this must match the scheme below - redirectUri: "fcscUaeStats://callback", // 👈 must match manifest + UAE PASS - scope: "urn:uae:digitalid:profile:general", - language: "en", - ); - } - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - appBar: AppBar( - title: const Text('UAE Pass'), - centerTitle: true, - ), - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - MaterialButton( - onPressed: () => login(), - child: const Text('Sign in with UAE Pass'), - ), - const SizedBox(height: 100), - if (authCode != null) - ListTile( - title: const Text('Auth Code'), - subtitle: Text('${authCode?.substring(0, 6)}............'), - ), - if (accessToken != null) - ListTile( - title: const Text('Access Token'), - subtitle: Text('${accessToken?.substring(0, 6)}............'), - ), - if (profileData != null) - Column( - children: [ - ListTile( - title: const Text('Emirates Id'), - subtitle: Text(profileData?.idn ?? ""), - ), - ListTile( - title: const Text('Full Name EN'), - subtitle: Text(profileData?.fullnameEN ?? ""), - ), - ListTile( - title: const Text('Full Name AR'), - subtitle: Text(profileData?.fullnameAR ?? ""), - ), - ListTile( - title: const Text('Mobile'), - subtitle: Text(profileData?.mobile ?? ""), - ), - ListTile( - title: const Text('Nationality EN'), - subtitle: Text(profileData?.nationalityEN ?? ""), - ), - ], - ), - ], - ), - ), - ), - ); - } - - Future login() async { - authCode = null; - accessToken = null; - profileData = null; - setState(() {}); - try { - authCode = await _uaePass.signIn(); - accessToken = await _uaePass.getAccessToken(authCode ?? ""); - profileData = await _uaePass.getProfile(accessToken ?? ""); - - setState(() {}); - } catch (e) { - // Ignore sign-in failures here; this screen is only for manual flow checks. - } - setState(() {}); - } -} \ No newline at end of file diff --git a/lib/presentation/routes/auth_routes/login_route.dart b/lib/presentation/routes/auth_routes/login_route.dart index c8070771..5f2af4f4 100755 --- a/lib/presentation/routes/auth_routes/login_route.dart +++ b/lib/presentation/routes/auth_routes/login_route.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -8,6 +9,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_uae_pass/flutter_uae_pass.dart'; import 'package:go_router/go_router.dart'; @@ -28,16 +30,27 @@ import 'package:uae_stat/presentation/components/dialogs.dart'; import 'package:uae_stat/presentation/components/space.dart'; import 'package:uae_stat/presentation/components/themed_text_field.dart'; import 'package:http/http.dart' as http; +import 'package:uae_stat/presentation/routes/auth_routes/oauth_webview.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:pocketbase/pocketbase.dart'; import 'package:uae_stat/config/api_config.dart'; import 'package:uae_stat/config/uae_pass_locale.dart'; +import 'package:uae_stat/config/uae_pass_oauth_errors.dart'; import 'package:uae_stat/config/uae_pass_oauth_state.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import '../../components/indicators/locale_provider.dart'; import '../../components/my_toggle.dart'; -class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ +class UserVisibleApiException implements Exception { + const UserVisibleApiException(this.message); + + final String message; + + @override + String toString() => message; +} + +class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver { final String? fromPage; final Map? userInfo; @@ -46,8 +59,9 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ this.fromPage, this.userInfo, }) : super(key: key); - // final pb = PocketBase('https://pb.venbait.in'); - final pb = PocketBase(apiUrl); + + /// Always use [PocketBaseService.client] so OAuth PKCE + realtime state match the rest of the app. + PocketBase get pb => PocketBaseService.client; static const deepLinkRedirect = 'ae.gov.fcsc.stats://auth-callback'; final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); // final _pb = PocketBase('http://127.0.0.1:8090'); @@ -69,6 +83,104 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ final UaePass _uaePass = UaePass(); bool _isInit = false; + /// Placeholder for upcoming pre-login linking API. + /// Return `{success: true, userId: "...", token: "..."}` to skip credential login block. + Future> _callLinkingPreLoginApi({ + required BuildContext context, + required WidgetRef ref, + required String username, + required String password, + }) async { + try { + final id = + (userInfo?['pbUserId'] ?? userInfo?['id'] ?? '').toString().trim(); + final uuid = + (userInfo?['uuid'] ?? userInfo?['sub'] ?? '').toString().trim(); + final email = (userInfo?['email'] ?? '').toString().trim(); + final languageCode = + Localizations.localeOf(context).languageCode == 'ar' ? 'ar' : 'en'; + + if (id.isEmpty || + uuid.isEmpty || + email.isEmpty || + username.trim().isEmpty || + password.isEmpty) { + return { + 'success': false, + 'userId': null, + 'token': null, + }; + } + + final url = Uri.parse( + '${apiUrl}api/MergeOpenIDConnectUserAfterAuthendicate', + ); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A', + }, + body: jsonEncode({ + 'id': id, + 'uuid': uuid, + 'email': email, + 'username': username.trim(), + 'password': password, + 'language': languageCode, + }), + ); + + debugPrint( + 'MergeOpenIDConnectUserAfterAuthendicate status=${response.statusCode} body=${response.body}', + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + String? message; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + message = decoded['message']?.toString(); + } + } catch (_) {} + return { + 'success': false, + 'userId': null, + 'token': null, + 'message': message, + }; + } + + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final status = (decoded['status'] ?? '').toString().toLowerCase(); + final message = (decoded['message'] ?? '').toString(); + final data = decoded['data']; + String? userId; + String? token; + if (data is Map) { + userId = (data['id'] ?? '').toString(); + token = (data['token'] ?? '').toString(); + } + return { + 'success': status == 'success', + 'userId': userId, + 'token': token, + 'message': message, + }; + } + } catch (e) { + debugPrint('MergeOpenIDConnectUserAfterAuthendicate failed: $e'); + } + + return { + 'success': false, + 'userId': null, + 'token': null, + 'message': null, + }; + } + Future profileStatus(String userId, WidgetRef ref) async { try { // Authenticate admin @@ -96,15 +208,13 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ }, ); - // Check if user is verified role = userDetailsResponse.data['role']; final bool? isVerified = userDetailsResponse.data['verified']; final bool? isUserMailVerified = - userDetailsResponse.data['user_mail_verify']; + userDetailsResponse.data['user_mail_verify']; preferredLanguage = userDetailsResponse.data['language'] ?? ''; - // Check admin and email verification statuses if (isVerified == false) { throw Exception('Admin not approved'); @@ -137,6 +247,8 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ await prefs.setString('login_count', count.toString()); } + /// YES path on FCSC linking: copy email/password user's PocketBase fields onto the + /// UAE PASS OAuth user (`pbUserId`), then delete the normal account and continue as OAuth user. Future _mergeUaepassIntoNormalUser({ required String normalUserId, required WidgetRef ref, @@ -144,70 +256,158 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ }) async { if (!cameFromLinking) return; - final uuid = (userInfo?['uuid'] ?? userInfo?['sub']) as String?; + final uuid = (userInfo?['uuid'] ?? userInfo?['sub'])?.toString().trim(); final uaepassRecordId = userInfo?['pbUserId'] as String?; final uaepassToken = userInfo?['pbToken'] as String?; - if (uuid == null || uuid.isEmpty) { + if (uuid == null || uuid.isEmpty) return; + if (uaepassRecordId == null || + uaepassRecordId.isEmpty || + uaepassToken == null || + uaepassToken.isEmpty) { + debugPrint( + 'UAE PASS merge: missing pbUserId/pbToken — cannot merge into OAuth user.', + ); + return; + } + if (uaepassRecordId == normalUserId) { + debugPrint('UAE PASS merge: OAuth record equals normal user — skipping.'); return; } - // 1) Update the normal account to attach UAE PASS UUID. + final normalToken = PocketBaseService.authStore.token; + if (normalToken.isEmpty) return; + try { - await pb.collection('users').update( - normalUserId, - body: { - 'emiratesid': uuid, - 'is_oauth_login': 1, - }, + final normalRecord = await pb.collection('users').getOne(normalUserId); + + final mergeBody = Map.from(normalRecord.data); + const skipKeys = { + 'password', + 'passwordConfirm', + 'oldPassword', + 'id', + 'collectionId', + 'collectionName', + 'created', + 'updated', + 'expand', + }; + mergeBody.removeWhere((k, _) => skipKeys.contains(k)); + mergeBody['uuid'] = uuid; + mergeBody['is_oauth_login'] = 1; + + // PocketBase enforces unique email/username. While the normal user still exists, + // setting these on the OAuth record causes a 400 duplicate error. + final desiredEmail = mergeBody['email']?.toString().trim(); + final desiredUsername = mergeBody['username']?.toString().trim(); + if ((desiredEmail != null && desiredEmail.isNotEmpty) || + (desiredUsername != null && desiredUsername.isNotEmpty)) { + final ts = DateTime.now().millisecondsSinceEpoch; + final normalUserTempBody = {}; + if (desiredEmail != null && desiredEmail.isNotEmpty) { + normalUserTempBody['email'] = + 'merged_${normalUserId}_$ts@merge.local'; + } + if (desiredUsername != null && desiredUsername.isNotEmpty) { + normalUserTempBody['username'] = 'merged_${normalUserId}_$ts'; + } + if (normalUserTempBody.isNotEmpty) { + await pb.collection('users').update( + normalUserId, + body: normalUserTempBody, + headers: { + 'Authorization': 'Bearer $normalToken', + }, + ); + } + } + + final oauthUpdated = await pb.collection('users').update( + uaepassRecordId, + body: mergeBody, headers: { - 'Authorization': 'Bearer ${PocketBaseService.authStore.token}', + 'Authorization': 'Bearer $uaepassToken', }, ); - } catch (e) { - } - // 2) Delete the temporary UAE PASS-created user row (best effort). - // This requires PocketBase rules to allow a user to delete their own record. - if (uaepassRecordId != null && - uaepassRecordId.isNotEmpty && - uaepassRecordId != normalUserId && - uaepassToken != null && - uaepassToken.isNotEmpty) { + await pb.collection('users').delete( + normalUserId, + headers: { + 'Authorization': 'Bearer $normalToken', + }, + ); + + pb.authStore.save(uaepassToken, oauthUpdated); + PocketBaseService.authStore.save(uaepassToken, oauthUpdated); + + final mergedAuth = RecordAuth( + token: uaepassToken, + record: oauthUpdated, + meta: {}, + ); try { - await pb.collection('users').delete( - uaepassRecordId, - headers: { - 'Authorization': 'Bearer $uaepassToken', - }, - ); + await ref + .read(authUseCaseProvider.notifier) + .googleAndAppleAuthencation(mergedAuth); } catch (e) { + // Linking path can yield an OAuth row with verified=false even though + // UAE PASS auth succeeded. Keep the active PB auth session and proceed. + if (e != LoginError.emailAddressNotVerified) { + rethrow; + } + debugPrint( + 'UAE PASS merge: continuing with OAuth session despite unverified email.', + ); } - } - // 3) Link the UAE PASS provider to the normal user so it shows under - // "Authorized providers" in PocketBase. - // - // This requires running the UAE PASS OAuth flow once while the normal - // user is already authenticated (PocketBase then attaches the provider - // to the current user record). - try { - // Ensure we call OAuth2 while authenticated as the normal user. - // Otherwise PocketBase will create/login a separate OAuth user instead of linking. - final normalToken = PocketBaseService.authStore.token; - final normalModel = PocketBaseService.authStore.model; - if (normalToken.isNotEmpty && normalModel != null) { - pb.authStore.save(normalToken, normalModel); + final oauthUserId = oauthUpdated.id; + + final url = Uri.parse('${apiUrl}api/login_success?id=$oauthUserId'); + try { + final response = await http.get( + url, + headers: {'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A'}, + ); + if (response.statusCode == 200) { + final count = jsonDecode(response.body); + loginCount = count['login_count']; + } + } catch (e) { + debugPrint('UAE PASS merge: login_success API error: $e'); + } + + await saveUserId(oauthUserId, loginCount); + await getDeviceToken( + context, + oauthUserId, + ref, + uaepassToken, + ); + await initializeTheme(context, ref, oauthUserId); + + if (!context.mounted) return; + + final bool isProfileComplete = await profileStatus(oauthUserId, ref); + + final String? intendedPath = + GoRouterState.of(context).uri.queryParameters['intendedPath']; + + if (UaePassOAuthState.instance.pendingUiLocales == 'ar' || + UaePassOAuthState.instance.pendingUiLocales == 'en') { + applyUaePassDashboardLocale(ref, context); } if (!context.mounted) return; - await oAuthGoogleAndAppleLogin( - context, - ref, - 'oidc', - linkToExistingAccount: true, - ); - } catch (e) { + + if (isProfileComplete) { + context.go(intendedPath ?? '/myhomepage'); + } else { + context.go('/profile/$oauthUserId'); + } + } catch (e, st) { + debugPrint('UAE PASS merge (normal → OAuth user): $e'); + debugPrint('$st'); } } @@ -229,29 +429,29 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ backgroundColor: isDark ? const Color(0xFF1F1F1F) : Colors.white, title: showErrorTitle ? Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircleAvatar( - radius: 18, - backgroundColor: Color(0xFFFFE9E9), - child: Icon( - Icons.priority_high_rounded, - color: Color(0xFFD83731), - size: 22, - ), - ), - const SizedBox(height: 8), - Text( - context.translate('Error!', 'خطأ!'), - textAlign: TextAlign.center, - style: TextStyle( - fontFamily: context.translate('Roboto', 'NotoKufi'), - fontWeight: FontWeight.w700, - color: isDark ? Colors.white : const Color(0xFF1A1A1A), - ), - ), - ], - ) + mainAxisSize: MainAxisSize.min, + children: [ + const CircleAvatar( + radius: 18, + backgroundColor: Color(0xFFFFE9E9), + child: Icon( + Icons.priority_high_rounded, + color: Color(0xFFD83731), + size: 22, + ), + ), + const SizedBox(height: 8), + Text( + context.translate('Error!', 'خطأ!'), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: context.translate('Roboto', 'NotoKufi'), + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : const Color(0xFF1A1A1A), + ), + ), + ], + ) : null, content: Text( message, @@ -266,7 +466,8 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () => Navigator.of(dialogContext, rootNavigator: true).pop(), + onPressed: () => + Navigator.of(dialogContext, rootNavigator: true).pop(), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFFB68A34), foregroundColor: Colors.white, @@ -360,8 +561,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ // // ); // // } // } - } catch (e) { - } + } catch (e) {} } Future updateDeviceToken( @@ -394,11 +594,11 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ headers: { 'Content-Type': 'application/json', // Add this 'Authorization': - 'Bearer $authtoken', // Correct way to send auth token + 'Bearer $authtoken', // Correct way to send auth token }, body: jsonEncode({ "device_token": token, - if (cameFromLinking) "emiratesid": userInfo?['uuid'], + if (cameFromLinking) "uuid": userInfo?['uuid'], }), ); @@ -415,7 +615,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ // Function to send the reset password request Future forgotPasswordMail(String email) async { final url = - Uri.parse("${pb.baseUrl}api/auth/request-password-reset?email=$email"); + Uri.parse("${pb.baseUrl}api/auth/request-password-reset?email=$email"); try { final response = await http @@ -483,10 +683,10 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ title: const Text('Enable App Links'), content: const Text( 'To enable links like www.fcsc.com to open in this app:\n\n' - '1. Tap "Open Settings".\n' - '2. Then tap on "Supported web addresses".\n' - '3. Make sure your domain is enabled.\n\n' - 'This helps open web links directly in the app.', + '1. Tap "Open Settings".\n' + '2. Then tap on "Supported web addresses".\n' + '3. Make sure your domain is enabled.\n\n' + 'This helps open web links directly in the app.', ), actions: [ TextButton( @@ -528,98 +728,6 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ } } - Future signInWithUAEPASS() async { - try { - print('signInWithUAEPASS-1'); - final authData = await pb.collection('users').authWithOAuth2( - // 'uaepass-stg', - 'oidc', - // (url) async { - // // Launch UAE PASS page - // await launchUrl(Uri.parse(url as String), - // mode: LaunchMode.externalApplication); - // }, - (uri) async { - // ✅ `uri` is already a Uri instance - await launchUrl(uri, mode: LaunchMode.externalApplication); - }, - - scopes: ['openid', 'profile', 'email'], - // redirectUrl: 'uaepassdemo://callback', - ); - - // final authData = await pb.collection('users').authWithOAuth2( - // 'uaepass-stg', // 'google' or 'apple' - // (providerUrl) async { - // final Uri uri = Uri.parse(providerUrl.toString()); - // - // // Inject redirect_url so PocketBase will redirect to the app when done - // final newUri = uri.replace(queryParameters: { - // ...uri.queryParameters, - // 'redirect_url': deepLinkRedirect, - // }); - // - // // Open in external browser (ensures OS handles returning to app) - // if (!await launchUrl(newUri, mode: LaunchMode.externalApplication)) { - // throw 'Could not launch OAuth URL'; - // } - // }, - // ); - print('signInWithUAEPASS-2'); - print('✅ Logged in user: ${authData}'); - } catch (e) { - print('❌ Error: $e'); - } - } - - Future login(BuildContext context, ref) async { - // final email = profileData?.email ?? 'surendarsuri3450@gmail.com'; - // final uuid = profileData?.uuid ?? 'eac45287-5e1b-4251-b338-6aa7f49464a0'; - // await checkUserWithUhid( - // email: email ?? '', - // uuid: uuid ?? '', - // context: context, - // userInfo: profileData, - // ); - authCode = null; - accessToken = null; - profileData = null; - try { - authCode = await _uaePass.signIn(); - print('authCode $authCode'); - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Auth Code'), - content: Text('authCode: $authCode'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ], - ), - ); - // accessToken = await _uaePass.getAccessToken(authCode ?? ""); - // print('accessToken $accessToken'); - // profileData = await _uaePass.getProfile(accessToken ?? ""); - // print('profileData $profileData'); - // final email = profileData?.email ?? ''; - // final uuid = profileData?.uuid ?? ''; - - // await checkUserWithUhid( - // email: email ?? '', - // uuid: uuid ?? '', - // context: context, - // userInfo: profileData, - // ); - } catch (e) { - if (kDebugMode) { - print(e.toString()); - } - } - } - Future initializeTheme( BuildContext context, WidgetRef ref, String userId) async { final themeService = ThemeBaseService(); @@ -633,180 +741,568 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ print('themeMode $themeMode'); } - /// UAE PASS mobile app-to-app flow: load auth URL in WebView so we can intercept - /// uaepassstg://, rewrite successURL/failureURL to our app scheme, then open UAE PASS app. - /// Auth completes when user returns via ae.gov.fcsc.stats://resume_authn and [UaePassResumeAuthScreen] runs. - Future _startUaePassMobileFlow(BuildContext context) async { - final methods = await pb.collection('users').listAuthMethods(); - final provider = - methods.authProviders.firstWhere((p) => p.name == 'oidc'); - final redirectUrl = '${pb.baseUrl}api/oauth2-redirect'; - final uiLocales = uaePassOidcUiLocalesFromContextOnly(context); - UaePassOAuthState.instance.setPending( - successUrl: '', - failureUrl: '', - codeVerifier: provider.codeVerifier, - redirectUrl: redirectUrl, - providerName: 'oidc', - uiLocales: uiLocales, - ); - // Build auth URL with mobile flow params. Must include redirect_uri or UAE PASS returns invalid.redirect.uri. - // Open in WebView first so we can intercept uaepassstg:// and rewrite successURL/failureURL to our app. - final authUri = Uri.parse(provider.authUrl).replace( - queryParameters: { - ...Uri.parse(provider.authUrl).queryParameters, - 'redirect_uri': redirectUrl, - 'scope': 'urn:uae:digitalid:profile:general', - 'acr_values': 'urn:safelayer:tws:policies:authentication:level:low', // Satging - // 'acr_values': 'urn:digitalid:authentication:flow:mobileondevice', // Production - 'ui_locales': uiLocales, - }, - ); - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => OAuthWebView( - authUrl: authUri.toString(), - useUaePassDeepLink: true, - ), - ), - ); + /// Returns true if a UAE PASS native app is installed (production or staging). + /// Live uses `uaepass://`; staging uses `uaepassstg://`. Probing only staging + /// makes [useUaePassDeepLink] false on prod-only devices and the WebView then + /// tries to load `uaepass://` inline → net::ERR_UNKNOWN_URL_SCHEME. + Future _isUaePassInstalled() async { + // iOS: Info.plist lists uaepass / uaepassstg / uaepassqa in LSApplicationQueriesSchemes. + final prod = await canLaunchUrl(Uri.parse('uaepass://')); + final stg = await canLaunchUrl(Uri.parse('uaepassstg://')); + final qa = await canLaunchUrl(Uri.parse('uaepassqa://')); + return prod || stg || qa; } - Future oAuthGoogleAndAppleLogin( - BuildContext context, - WidgetRef ref, - platform, { - bool linkToExistingAccount = false, - }) async { - print('platform $platform'); + /// PocketBase `auth-with-oauth2` errors include JSON in [ClientException.response]. + String _formatPocketBaseAuthFailure(Object error) { + if (error is ClientException) { + final parts = [ + 'statusCode=${error.statusCode}', + if (error.url != null) 'url=$error.url', + if (error.response.isNotEmpty) 'response=${error.response}', + if (error.originalError != null) 'originalError=${error.originalError}', + ]; + return 'ClientException(${parts.join(', ')})'; + } + return error.toString(); + } + + /// UAE PASS documented cancellation codes → EN/AR copy (reference table). + String? _translateUaePassCancellationMessage( + BuildContext context, + UaePassOAuthFlowKind flowKind, + String code, + ) { + if (flowKind == UaePassOAuthFlowKind.authentication && + uaePassIsUserCancelledLogin(code)) { + return context.translate( + 'User cancelled the login', + 'قام المستخدم بإلغاء تسجيل الدخول', + ); + } + if (flowKind == UaePassOAuthFlowKind.digitalSignature && + uaePassIsUserCancelledSigning(code)) { + return context.translate( + 'User cancelled the signing process', + 'قام المستخدم بإلغاء عملية التوقيع', + ); + } + return null; + } + + /// Mobile: OAuth code is delivered to [`auth-with-oauth2`] by exchanging the + /// redirect URL `code` with PKCE — **not** only via SSE `@oauth2` (SSE often + /// fails after switching to the UAE PASS app). + Future _signInWithUaePassMobileApp( + BuildContext context, + WidgetRef ref, { + required bool uaePassInstalled, + required String acrValues, + }) async { + final pb = PocketBaseService.client; + final methods = await pb.collection('users').listAuthMethods(); + final provider = methods.authProviders.firstWhere((p) => p.name == 'oidc'); + final redirectUri = pb.buildUrl('/api/oauth2-redirect'); + + final unsubConnect = await pb.realtime.subscribe('PB_CONNECT', (_) {}); try { - final authData; - if (platform == 'oidc') { - // UAE PASS via PocketBase authWithOAuth2. - // On mobile: use in-app WebView. On web: open in external browser - // because UAE PASS sets X-Frame-Options=DENY and cannot load in an iframe. - authData = await pb.collection('users').authWithOAuth2( + final clientId = pb.realtime.clientId; + if (clientId.isEmpty) { + throw StateError( + 'PocketBase realtime clientId is empty — SSE did not connect.', + ); + } + + final authUrl = Uri.parse(provider.authUrl + redirectUri.toString()); + final qp = Map.from(authUrl.queryParameters); + qp['state'] = clientId; + qp['scope'] = 'urn:uae:digitalid:profile:general'; + qp['acr_values'] = acrValues; + qp['ui_locales'] = uaePassOidcUiLocales(context, ref); + + final mobileUri = authUrl.replace(queryParameters: qp); + + final authCompleter = Completer(); + + Future handleOAuthError( + String errorCode, + String? errorDescription, + ) async { + if (authCompleter.isCompleted) return; + authCompleter.completeError( + UaePassOAuthRedirectException( + errorCode, + errorDescription: errorDescription, + flowKind: UaePassOAuthFlowKind.authentication, + ), + ); + } + + Future handleRedirect(String code, String? oauthState) async { + if (authCompleter.isCompleted) { + return; + } + try { + final auth = await pb.collection('users').authWithOAuth2Code( + 'oidc', + code, + provider.codeVerifier, + redirectUri.toString(), + ); + if (!authCompleter.isCompleted) { + authCompleter.complete(auth); + } + } catch (e, st) { + debugPrint( + 'UAE PASS auth-with-oauth2 FAILED: ${_formatPocketBaseAuthFailure(e)}', + ); + if (e is ClientException && e.response.isNotEmpty) { + try { + debugPrint( + 'UAE PASS PocketBase failure JSON: ${jsonEncode(e.response)}', + ); + } catch (_) { + debugPrint('UAE PASS PocketBase failure response: ${e.response}'); + } + } + if (!authCompleter.isCompleted) { + authCompleter.completeError(e, st); + } + } + } + + if (!context.mounted) { + throw StateError('Context unmounted before UAE PASS WebView'); + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OAuthWebView( + authUrl: mobileUri.toString(), + useUaePassDeepLink: uaePassInstalled, + onPocketBaseRedirect: handleRedirect, + onPocketBaseOAuthError: handleOAuthError, + ), + ), + ); + + return await authCompleter.future; + } finally { + await unsubConnect(); + } + } + +// ── Main auth entry point ────────────────────────────────────────────────── + + Future signInWithUaePass( + BuildContext context, + WidgetRef ref, + ValueNotifier isFlowActive, + ) async { + isFlowActive.value = true; + var sdkOAuthCompleted = false; + try { + // Check app presence ONCE before starting OAuth. + final uaePassInstalled = await _isUaePassInstalled(); + + // acr_values per UAE PASS docs: + // app installed -> mobileondevice (M2M flow) + // app missing -> level:low (web-form / push-to-other-device) + final acrValues = uaePassInstalled + ? 'urn:digitalid:authentication:flow:mobileondevice' + : 'urn:digitalid:authentication:flow:mobileondevice'; + // : 'urn:safelayer:tws:policies:authentication:level:low'; + + UaePassOAuthState.instance.clear(); + + debugPrint( + 'UAE PASS sign-in started. installed=$uaePassInstalled, acr_values=$acrValues', + ); + + // Web: SDK completes OAuth via SSE `@oauth2` (same browser session). + // Mobile: UAE PASS app-switch breaks SSE state alignment — exchange redirect + // `code` via [OAuthWebView.onPocketBaseRedirect] + [authWithOAuth2Code]. + final RecordAuth recordAuth; + if (kIsWeb) { + recordAuth = await pb.collection('users').authWithOAuth2( 'oidc', - (url) async { - print('UAE PASS URL $url'); + (url) async { + debugPrint('UAE PASS URL $url'); final uiLocales = uaePassOidcUiLocales(context, ref); UaePassOAuthState.instance.pendingUiLocales = uiLocales; final mobileUri = url.replace( queryParameters: { ...url.queryParameters, - 'acr_values': - 'urn:safelayer:tws:policies:authentication:level:low', + 'scope': 'urn:uae:digitalid:profile:general', + 'acr_values': acrValues, 'ui_locales': uiLocales, - // 'redirect_url': 'ae.gov.fcsc.stats://auth-callback', }, ); - print(mobileUri); - - if (kIsWeb) { - // For Flutter Web, let the browser handle the redirect. - if (!await launchUrl( - mobileUri, - mode: LaunchMode.externalApplication, - )) { - throw Exception('Could not open UAE PASS URL in browser'); - } - } else { - if (!context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => OAuthWebView( - authUrl: mobileUri.toString(), - useUaePassDeepLink: false, - ), - ), - ); + debugPrint('UAE PASS mobileUri $mobileUri'); + if (!await launchUrl( + mobileUri, + mode: LaunchMode.externalApplication, + )) { + throw Exception('Could not open UAE PASS URL in browser'); } }, + ).timeout( + const Duration(seconds: 120), + onTimeout: () { + throw TimeoutException( + 'UAE PASS login timed out — check PocketBase realtime (SSE) to ${pb.baseUrl}', + ); + }, ); } else { - // Google must use external browser. Apple uses OAuthWebView on mobile per app flow. - authData = await pb.collection('users').authWithOAuth2( - platform, - (url) async { - if (platform == 'apple' && !kIsWeb) { - if (!context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => OAuthWebView( - authUrl: url.toString(), - useUaePassDeepLink: false, - ), - ), - ); - } else { - // Google must keep the provider URL/state untouched for PB realtime OAuth flow. - final uri = Uri.parse(url.toString()); - final launched = await launchUrl( - uri, - mode: LaunchMode.externalApplication, - ); - if (!launched) { - throw Exception('Could not open OAuth URL'); - } - } + recordAuth = await _signInWithUaePassMobileApp( + context, + ref, + uaePassInstalled: uaePassInstalled, + acrValues: acrValues, + ).timeout( + const Duration(seconds: 120), + onTimeout: () { + throw TimeoutException( + 'UAE PASS login timed out — complete login in WebView or try again.', + ); }, ); } - print('authDataOiDC $authData'); + if (recordAuth.token.isEmpty || recordAuth.record == null) { + throw StateError('UAE PASS login returned empty auth'); + } - // For UAE PASS (oidc), decide whether to go straight to home or to the - // manual profile-linking flow based on the UAE PASS UUID. - if (platform == 'oidc' && !linkToExistingAccount) { + sdkOAuthCompleted = true; + pb.authStore.save(recordAuth.token, recordAuth.record!); + PocketBaseService.authStore.save(recordAuth.token, recordAuth.record!); + + debugPrint( + 'UAE PASS PocketBase OAuth finished via SDK (auth-with-oauth2): $sdkOAuthCompleted', + ); + + final meta = recordAuth.meta as Map?; + final rawUser = meta?['rawUser'] as Map?; + final uuid = rawUser?['uuid'] ?? rawUser?['sub']; + final mail = rawUser?['email'] ?? rawUser?['sub']; + debugPrint('UAE PASS Mail: $mail'); + debugPrint('UAE PASS UUID: $uuid'); + debugPrint('UAE PASS authData: ${recordAuth.toJson()}'); + + return recordAuth; + } catch (e, st) { + debugPrint( + 'UAE PASS PocketBase OAuth finished via SDK (auth-with-oauth2): $sdkOAuthCompleted', + ); + debugPrint( + 'UAE PASS sign-in failed: ${_formatPocketBaseAuthFailure(e)}', + ); + if (e is ClientException && e.response.isNotEmpty) { try { - final meta = authData.meta as Map?; - final rawUser = meta?['rawUser'] as Map?; - final uuid = rawUser?['uuid'] ?? rawUser?['sub']; - if (uuid is String && uuid.isNotEmpty) { - // Check if any existing PocketBase user is already linked to this UAE PASS UUID. - final existing = await pb.collection('users').getList( - page: 1, - perPage: 1, - filter: 'emiratesid = "$uuid"', - ); - if (existing.items.isEmpty) { - // First, persist a valid session so protected routes (like /profile) - // won't redirect back to /login due to "Session expired". - try { - await ref - .read( - authUseCaseProvider.notifier, - ) - .googleAndAppleAuthencation(authData); - } catch (e) { - debugPrint( - 'UAEPASS session store error before linking screen: $e'); - } - - // First-time UAE PASS login: show manual linking screen. - if (context.mounted) { - print('existing.items.isEmpty'); - // Clone rawUser and attach the PocketBase record id so the - // linking/registration screens can update the same record. - final Map linkingUser = - Map.from(rawUser ?? const {}); - linkingUser['pbUserId'] = authData.record?.id; - linkingUser['pbToken'] = authData.token; - - GoRouter.of(context).go( - '/fcscprofilelinking', - extra: linkingUser, - ); - } - return; - } - } - } catch (e) { - // If anything goes wrong in the linking check, fall back to normal login. - debugPrint('UAEPASS linking check error: $e'); + debugPrint( + 'UAE PASS PocketBase failure JSON: ${jsonEncode(e.response)}', + ); + } catch (_) { + debugPrint('UAE PASS PocketBase failure response: ${e.response}'); } } + debugPrint('$st'); + if (context.mounted) { + if (e is TimeoutException) { + await _showUaePassStyledDialog( + context: context, + message: context.translate( + 'UAE PASS is taking too long to respond. Please try again.', + 'استغرق UAE PASS وقتًا أطول من المتوقع. يرجى المحاولة مرة أخرى.', + ), + actionLabel: context.translate('OK', 'موافق'), + showErrorTitle: true, + ); + } else { + String? cancellationMsg; + if (e is UaePassOAuthRedirectException) { + cancellationMsg = _translateUaePassCancellationMessage( + context, + e.flowKind, + e.errorCode, + ); + } else if (e is ClientException && e.response.isNotEmpty) { + final code = + uaePassTryExtractOAuthErrorCodeFromResponse(e.response); + if (code != null) { + cancellationMsg = _translateUaePassCancellationMessage( + context, + UaePassOAuthFlowKind.authentication, + code, + ); + } + } + if (cancellationMsg != null) { + await _showUaePassStyledDialog( + context: context, + message: cancellationMsg, + actionLabel: context.translate('OK', 'موافق'), + showErrorTitle: false, + ); + } + // else { + // await _showUaePassStyledDialog( + // context: context, + // message: context.translate( + // 'UAE PASS sign-in failed. Please try again.', + // 'فشل تسجيل الدخول عبر UAE PASS. يرجى المحاولة مرة أخرى.', + // ), + // actionLabel: context.translate('OK', 'موافق'), + // showErrorTitle: true, + // ); + // } + } + } + return null; + } finally { + isFlowActive.value = false; + } + } + + /// After [signInWithUaePass] returns a [RecordAuth], run the same UAE PASS UUID / + /// profile-linking checks and session + routing as [oAuthGoogleAndAppleLogin] for `oidc`. + Future _continueAfterUaePassRecordAuth( + BuildContext context, + WidgetRef ref, + RecordAuth authData, { + bool linkToExistingAccount = false, + }) async { + var mergeOpenIdSuccess = false; + Map? mergeOpenIdData; + if (!linkToExistingAccount) { + try { + final meta = authData.meta as Map?; + final rawUser = meta?['rawUser'] as Map?; + final uuid = rawUser?['uuid'] ?? rawUser?['sub']; + final uuidStr = uuid?.toString().trim() ?? ''; + final emailID = (rawUser?['email'] ?? '').toString().trim(); + final id = authData.record?.id ?? ''; + if (id.isNotEmpty && emailID.isNotEmpty && uuidStr.isNotEmpty) { + final mergeResponse = await _callMergeOpenIDConnectUserApi( + id: id, + uuid: uuidStr, + email: emailID, + ); + mergeOpenIdSuccess = mergeResponse['success'] == true; + final mergeMessage = + (mergeResponse['message'] ?? '').toString().trim(); + final mergeStatusCode = mergeResponse['statusCode'] as int? ?? 0; + final mergeCode = + int.tryParse((mergeResponse['code'] ?? '').toString()) ?? 0; + if (!mergeOpenIdSuccess && + mergeMessage.isNotEmpty && + (mergeStatusCode >= 400 || mergeCode >= 400)) { + throw UserVisibleApiException(mergeMessage); + } + final rawData = mergeResponse['data']; + if (rawData is Map) { + mergeOpenIdData = rawData; + } + } + // If MergeOpenIDConnectUser succeeds, skip the profile-linking branch + // and continue with the regular post-auth flow below. + if (!mergeOpenIdSuccess && uuid is String && uuid.isNotEmpty) { + final existing = await pb.collection('users').getList( + page: 1, + perPage: 1, + filter: 'uuid = "$uuid"', + ); + if (existing.items.isEmpty) { + try { + await ref + .read(authUseCaseProvider.notifier) + .googleAndAppleAuthencation(authData); + } catch (e) { + debugPrint( + 'UAEPASS session store error before linking screen: $e', + ); + } + + if (context.mounted) { + final Map linkingUser = + Map.from(rawUser ?? const {}); + linkingUser['pbUserId'] = authData.record?.id; + linkingUser['pbToken'] = authData.token; + + GoRouter.of(context).go( + '/fcscprofilelinking', + extra: linkingUser, + ); + } + return; + } + } + } on UserVisibleApiException { + rethrow; + } catch (e) { + debugPrint('UAEPASS linking check error: $e'); + } + } + + if (authData.token.isEmpty || authData.record == null) { + debugPrint('UAE PASS: invalid authData after sign-in'); + return; + } + + final userDetails = authData.record!; + final userId = userDetails.id; + var isProfileComplete = userDetails.data['is_profile_completed'] ?? ''; + if (mergeOpenIdSuccess && + mergeOpenIdData != null && + mergeOpenIdData.containsKey('is_profile_completed')) { + isProfileComplete = mergeOpenIdData['is_profile_completed']; + } + + await ref + .read(authUseCaseProvider.notifier) + .googleAndAppleAuthencation(authData); + + await loginCountApi(userId); + await initializeTheme(context, ref, userId); + + final String? intendedPath = + GoRouterState.of(context).uri.queryParameters['intendedPath']; + + if (linkToExistingAccount && context.mounted) { + applyUaePassDashboardLocale(ref, context); + context.go(intendedPath ?? '/myhomepage'); + return; + } + + if (isProfileComplete) { + applyUaePassDashboardLocale(ref, context); + if (context.mounted) { + context.go(intendedPath ?? '/myhomepage'); + } + } else { + applyUaePassDashboardLocale(ref, context); + if (context.mounted) { + context.go('/profile/$userId'); + } + } + } + + Future> _callMergeOpenIDConnectUserApi({ + required String id, + required String uuid, + required String email, + }) async { + try { + final url = Uri.parse('${apiUrl}api/MergeOpenIDConnectUser'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A', + }, + body: jsonEncode({ + 'id': id, + 'uuid': uuid, + 'email': email, + }), + ); + debugPrint( + 'MergeOpenIDConnectUser status=${response.statusCode} body=${response.body}', + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + String? message; + dynamic code; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + message = decoded['message']?.toString(); + code = decoded['code']; + } + } catch (_) {} + return { + 'success': false, + 'data': null, + 'message': message, + 'code': code, + 'statusCode': response.statusCode, + }; + } + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final status = (decoded['status'] ?? '').toString().toLowerCase(); + final message = (decoded['message'] ?? '').toString(); + final data = decoded['data']; + return { + 'success': status == 'success', + 'data': data is Map ? data : null, + 'message': message, + 'code': decoded['code'], + 'statusCode': response.statusCode, + }; + } + return { + 'success': false, + 'data': null, + 'message': null, + 'code': null, + 'statusCode': response.statusCode, + }; + } catch (e) { + debugPrint('MergeOpenIDConnectUser API failed: $e'); + return { + 'success': false, + 'data': null, + 'message': null, + 'code': null, + 'statusCode': null, + }; + } + } + + String _uaePassUiLocales(BuildContext context, WidgetRef ref) { + // Return 'ar' or 'en' based on current app locale. + final locale = Localizations.localeOf(context).languageCode; + return locale == 'ar' ? 'ar' : 'en'; + } + + Future oAuthGoogleAndAppleLogin( + BuildContext context, WidgetRef ref, platform) async { + print('platform $platform'); + try { + final authData; + + // Google must use external browser. Apple uses OAuthWebView on mobile per app flow. + authData = await pb.collection('users').authWithOAuth2( + platform, + (url) async { + if (platform == 'apple' && !kIsWeb) { + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OAuthWebViewApple( + authUrl: url.toString(), + useUaePassDeepLink: false, + ), + ), + ); + } else { + // Google must keep the provider URL/state untouched for PB realtime OAuth flow. + final uri = Uri.parse(url.toString()); + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + print('launched $launched'); + if (!launched) { + throw Exception('Could not open OAuth URL'); + } + } + }, + ); + + print('authDataOiDC $authData'); + // ✅ Check if authData is valid if (authData.token.isNotEmpty && authData.record != null) { print("✅ Login Successful"); @@ -827,15 +1323,15 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ print("Email: ${loginCount}"); final session = await context.loaderWithErrorDialog( - () => ref + () => ref .read( - authUseCaseProvider.notifier, - ) + authUseCaseProvider.notifier, + ) .googleAndAppleAuthencation(authData), errorDialogBuilder: ( - error, [ - StackTrace? stackTrace, - ]) { + error, [ + StackTrace? stackTrace, + ]) { if (error == LoginError.invalidEmailPw) { return context.simpleDialog( title: context.translate( @@ -855,13 +1351,11 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ print("sessionTST - $session"); // Post-login side effects should not run for UAE PASS flow. - if (platform != 'oidc') { - await updateOAuthUserDetails(userId!, metaDetails, ref, token); - // await saveUserId(userId!, loginCount); - await getDeviceToken(context, userId, ref, token); + await updateOAuthUserDetails(userId!, metaDetails, ref, token); + // await saveUserId(userId!, loginCount); + await getDeviceToken(context, userId, ref, token); - } await loginCountApi(userId!); await initializeTheme(context, ref, userId); @@ -870,54 +1364,30 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ // Extract intendedPath from query parameters (if coming from redirect) final String? intendedPath = - GoRouterState.of(context).uri.queryParameters['intendedPath']; + GoRouterState.of(context).uri.queryParameters['intendedPath']; print("Extracted intendedPath after login: $intendedPath"); // If we are linking the UAE PASS provider to an already logged-in account, // don't redirect to profile-linking flow and just continue to home. - if (linkToExistingAccount && context.mounted) { - if (platform == 'oidc') { - applyUaePassDashboardLocale(ref, context); - } - context.go(intendedPath ?? '/myhomepage'); - return; - } if (isProfileComplete) { print('Redirecting to Home / Intended Path'); - if (platform == 'oidc') { - applyUaePassDashboardLocale(ref, context); + + // **Set Default Locale in Provider** + if (preferredLanguage == 'ar') { + ref.read(localeProvider.notifier).setLocale(const Locale('ar')); } else { - // **Set Default Locale in Provider** - if (preferredLanguage == 'ar') { - ref.read(localeProvider.notifier).setLocale(const Locale('ar')); - } else { - ref.read(localeProvider.notifier).setLocale(const Locale('en')); - } + ref.read(localeProvider.notifier).setLocale(const Locale('en')); } context.go(intendedPath ?? '/myhomepage'); } else { print('Redirecting to Profile Setup'); - if (platform == 'oidc') { - applyUaePassDashboardLocale(ref, context); - } - context.go('/profile/$userId'); } } else { print("❌ Login failed: Invalid authData"); // UAE PASS user journey §9: User cancel scenario - if (platform == 'oidc' && context.mounted) { - await _showUaePassStyledDialog( - context: context, - message: context.translate( - 'User cancelled the login', - 'قام المستخدم بإلغاء تسجيل الدخول', - ), - actionLabel: context.translate('Close', 'إغلاق'), - ); - } } } catch (e) { print("❌ Error during OAuth login ($platform): $e"); @@ -961,9 +1431,9 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ // Extra best-effort extraction from the string form. extractedErrorCode ??= (() { - final match = RegExp(r'error[^a-zA-Z0-9_]*([a-zA-Z0-9_]+)', - caseSensitive: false) - .firstMatch(e.toString()); + final match = + RegExp(r'error[^a-zA-Z0-9_]*([a-zA-Z0-9_]+)', caseSensitive: false) + .firstMatch(e.toString()); return match?.group(1); })(); @@ -1007,64 +1477,20 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ msg.contains('visitor account') || msg.contains('not upgraded'); final isExistingUsersOnly = msg.contains('only for registered users'); - final isUaeNationalsOnly = msg.contains('only accesible to uae nationals') || - msg.contains('only accessible to uae nationals'); - if (platform == 'oidc') { - String content = context.translate( - 'Something went wrong during the login, please try again later!', - 'حدث خطأ ما أثناء تسجيل الدخول، يرجى المحاولة مرة أخرى لاحقًا!', - ); - String actionLabel = context.translate('Try Again', 'أعد المحاولة'); - bool showErrorTitle = false; + final isUaeNationalsOnly = + msg.contains('only accesible to uae nationals') || + msg.contains('only accessible to uae nationals'); - if (isCancel) { - content = context.translate( - 'User cancelled the login', - 'قام المستخدم بإلغاء تسجيل الدخول', - ); - actionLabel = context.translate('Close', 'إغلاق'); - } else if (isSop1Unverified) { - content = context.translate( - 'You are not eligible to access this service. Your account is either not upgraded or you have a visitor account. Please contact FCSC to access the services.', - 'أنت غير مؤهل للوصول إلى هذه الخدمة. إما أن حسابك لم تتم ترقيته أو لديك حساب زائر. يرجى التواصل مع FCSC للوصول إلى الخدمات.', - ); - actionLabel = context.translate('Close', 'إغلاق'); - showErrorTitle = true; - } else if (isExistingUsersOnly) { - content = context.translate( - 'This service is only for registered users, please contact FCSC in order to access the services.', - 'هذه الخدمة مخصصة فقط للمستخدمين المسجلين، يرجى التواصل مع FCSC لتتمكن من الوصول إلى الخدمات.', - ); - actionLabel = context.translate('Close', 'إغلاق'); - showErrorTitle = true; - } else if (isUaeNationalsOnly) { - content = context.translate( - 'This service is only accesible to UAE Nationals', - 'هذه الخدمة متاحة فقط لمواطني دولة الإمارات العربية المتحدة.', - ); - actionLabel = context.translate('Close', 'إغلاق'); - showErrorTitle = true; - } - - await _showUaePassStyledDialog( - context: context, - message: content, - actionLabel: actionLabel, - showErrorTitle: showErrorTitle, - ); - if (context.mounted) context.go('/login'); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.translate( - 'Something went wrong during the login, please try again later!', - 'حدث خطأ ما أثناء تسجيل الدخول، يرجى المحاولة مرة أخرى لاحقًا!', - ), + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.translate( + 'Something went wrong during the login, please try again later!', + 'حدث خطأ ما أثناء تسجيل الدخول، يرجى المحاولة مرة أخرى لاحقًا!', ), ), - ); - } + ), + ); } } @@ -1126,7 +1552,8 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ "user_mail_verify": true, "reviewed": true, "role": 'user', - "uname": userDetails['name'], + "uname": userDetails['given_name'], + "full_name": userDetails['name'], "status": 'Approved', "is_oauth_login": 1, }, @@ -1258,7 +1685,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ final body = jsonEncode({ // 'email': email, // 'uuid': uuid, - 'code' : code + 'code': code }); try { @@ -1298,14 +1725,13 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ "expiresOn": 1765271521, "message": "User found by email and UUID updated", "status": "success", - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3NjUyNzE1MjEsImlkIjoiaXd4dmF4Mm0zcmE4dnJ5IiwidHlwZSI6ImF1dGhSZWNvcmQifQ.jSXeGByFWsPTv70nW2YvahZi8fYCzfibF8nWqhKYPA0" - + "token": + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3NjUyNzE1MjEsImlkIjoiaXd4dmF4Mm0zcmE4dnJ5IiwidHlwZSI6ImF1dGhSZWNvcmQifQ.jSXeGByFWsPTv70nW2YvahZi8fYCzfibF8nWqhKYPA0" }; print('✅ Success: $apiParam'); if (apiParam['token'] != null && apiParam['token'].toString().isNotEmpty && apiParam['status'] == 'success') { - print("✔ Token exists & login success!"); // final metaDetails = authData.meta; @@ -1314,7 +1740,8 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ final userId = userDetails['id']; final email = userDetails['email'] ?? ''; final uname = userDetails['uname'] ?? ''; - final isProfileComplete = userDetails['is_profile_completed'] ?? false; + final isProfileComplete = + userDetails['is_profile_completed'] ?? false; final loginCount = userDetails['login_count'] ?? 0; // final expiresOn = apiParam['expiresOn']; @@ -1324,15 +1751,15 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ print("Email: ${loginCount}"); final session = await context.loaderWithErrorDialog( - () => ref + () => ref .read( - authUseCaseProvider.notifier, - ) + authUseCaseProvider.notifier, + ) .googleAndAppleAuthencation(apiParam), errorDialogBuilder: ( - error, [ - StackTrace? stackTrace, - ]) { + error, [ + StackTrace? stackTrace, + ]) { if (error == LoginError.invalidEmailPw) { return context.simpleDialog( title: context.translate( @@ -1362,7 +1789,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ // Extract intendedPath from query parameters (if coming from redirect) final String? intendedPath = - GoRouterState.of(context).uri.queryParameters['intendedPath']; + GoRouterState.of(context).uri.queryParameters['intendedPath']; print("Extracted intendedPath after login: $intendedPath"); if (isProfileComplete) { @@ -1393,11 +1820,13 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ } String buildUaepassAuthorizationUrl() { - const String authorizeEndpoint = "https://stg-id.uaepass.ae/idshub/authorize"; + const String authorizeEndpoint = + "https://stg-id.uaepass.ae/idshub/authorize"; const String clientId = "fcsc_uae_stat_mob_stage"; const String scope = "urn:uae:digitalid:profile:general"; const String redirectUri = "https://pb.venbait.in/api/oauth2-redirect"; - const String acrValues = "urn:safelayer:tws:policies:authentication:level:low"; + const String acrValues = + "urn:safelayer:tws:policies:authentication:level:low"; // Generate random state (recommended) final String state = DateTime.now().millisecondsSinceEpoch.toString(); @@ -1429,9 +1858,8 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ // } // } - Future openUaepassLogin(BuildContext context) async { - final authUrl = buildUaepassAuthorizationUrl(); // Now this exists + final authUrl = buildUaepassAuthorizationUrl(); // Now this exists print('UAE Pass :$authUrl'); @@ -1469,7 +1897,8 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ const String clientId = "fcsc_uae_stat_mob_stage"; const String clientSecret = "BcamcqPfkrZog"; // For POC both are same - const String redirectUri = "https://pb.venbait.in/api/oauth2-redirect"; // use your redirect URI + const String redirectUri = + "https://pb.venbait.in/api/oauth2-redirect"; // use your redirect URI // Encode Basic Auth: base64(clientId:clientSecret) final String basicAuth = @@ -1498,38 +1927,6 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ } } - Future _callUaepassTokenAPI(BuildContext context,WidgetRef ref, String code) async { - try { - final String safeCode = (code is String) ? code : code?.toString() ?? ''; - - print("🔐 Calling UAE PASS Token API with code: $safeCode"); - - // TODO: Implement your API call here - // Example: - // final token = await ref.read(authProvider.notifier).loginWithUaepass(code); - - // final tokenData = await fetchUaepassToken(code); - // - // final accessToken = tokenData['access_token']; - // final refreshToken = tokenData['refresh_token']; - // print("✅ Access Token: $accessToken"); - // print("🔄 Refresh Token: $refreshToken"); - // final userInfo = await fetchUaepassUserInfo(accessToken); - // - // print("👤 UAE PASS User Info:"); - // print(userInfo); - // - await checkUserWithUhid( - code: safeCode, - ref:ref, - context: context, - ); - print("✅ Token received and processed successfully"); - } catch (e) { - print("❌ UAE PASS token exchange failed: $e"); - } - } - Future> fetchUaepassUserInfo(String accessToken) async { const String userInfoUrl = "https://stg-id.uaepass.ae/idshub/userinfo"; @@ -1555,7 +1952,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ try { // NOTE: no redirectUrl named parameter here final authData = - await pb.collection('users').authWithOAuth2(provider, (url) async { + await pb.collection('users').authWithOAuth2(provider, (url) async { await launchUrl(url); }); print('PB token: ${pb.authStore.token}'); @@ -1565,7 +1962,6 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ } } - @override Widget build(BuildContext context, WidgetRef ref) { double screenHeight = MediaQuery.of(context).size.height; @@ -1573,18 +1969,9 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ final isTablet = screenWidth > 600; final uaePass = useMemoized(() => UaePass()); final isInit = useState(false); - final params = GoRouterState.of(context).uri.queryParameters; final uaePassCode = params['uaeCode']; - useEffect(() { - if (uaePassCode != null) { - print("🚀 UAE PASS code received: $uaePassCode"); - _callUaepassTokenAPI(context,ref, uaePassCode); - } - return null; - }, [uaePassCode]); - // Initialize UAE PASS once when widget loads useEffect(() { Future.microtask(() async { @@ -1603,19 +1990,18 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ return null; }, []); - /// ------------------------------------------------------- /// POCKETBASE OAUTH AUTO-CLOSE (RESUME DETECTION) /// ------------------------------------------------------- - useEffect(() { - final observer = _OAuthLifecycleObserver( - pb: pb, - context: context, - ); - - WidgetsBinding.instance.addObserver(observer); - return () => WidgetsBinding.instance.removeObserver(observer); - }, []); + // useEffect(() { + // final observer = _OAuthLifecycleObserver( + // pb: pb, + // context: context, + // ); + // + // WidgetsBinding.instance.addObserver(observer); + // return () => WidgetsBinding.instance.removeObserver(observer); + // }, []); if (!isInit.value) { return const Scaffold( @@ -1630,7 +2016,9 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ ref.listen>(connectivityProvider, (previous, hasInternet) { if (hasInternet.value == false) { - context.push('/internetcheck'); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) context.push('/internetcheck'); + }); } }); @@ -1654,16 +2042,35 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ // } final hasValidated = useState(false); + final isUaePassFlowActive = useState(false); final locale = ref.watch(localeProvider); final emailCtl = useTextEditingController(); final pwCtl = useTextEditingController(); useEffect(() { - // Clear controllers when the widget is built - emailCtl.clear(); - pwCtl.clear(); - formKey.currentState?.reset(); - return null; // No cleanup needed - }, const []); + void syncFormFromProps() { + emailCtl.clear(); + pwCtl.clear(); + formKey.currentState?.reset(); + + final hasFromPage = fromPage != null && fromPage!.isNotEmpty; + final info = userInfo; + if (hasFromPage && info != null && info.isNotEmpty) { + final raw = info['email'] ?? info['Email']; + final emailStr = raw?.toString().trim(); + if (emailStr != null && emailStr.isNotEmpty) { + emailCtl.text = emailStr; + } + } + } + + // Avoid clear()/Form.reset() during build — e.g. after UAE PASS when + // [userInfo]/[fromPage] update and hooks rerun while TextFormFields build. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + syncFormFromProps(); + }); + return null; + }, [fromPage, userInfo]); final forgotPwBtn = TextButton( onPressed: () async { final forgotPwFormKey = GlobalKey(); @@ -1761,7 +2168,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ color: Color(0xFF92722A)), // Border outline color shape: RoundedRectangleBorder( borderRadius: - BorderRadius.circular(8), // Optional: Rounded corners + BorderRadius.circular(8), // Optional: Rounded corners ), padding: EdgeInsets.symmetric( horizontal: 10, vertical: 10), // Optional: Padding @@ -1882,74 +2289,100 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ if (!isValid) return; var mailID = emailCtl.text; var email = mailID.toLowerCase(); - final session = await context.loaderWithErrorDialog( - () => ref - .read( - authUseCaseProvider.notifier, - ) - .login( - email, - pwCtl.text, - ), - errorDialogBuilder: ( - error, [ - StackTrace? stackTrace, - ]) { - if (error == LoginError.invalidEmailPw) { - return context.simpleDialog( + String userId = ''; + String authToken = PocketBaseService.authStore.token; + + // If coming from linking, try the pre-login merge API first. + // On success, skip credential login and continue remaining flow. + if (cameFromLinking) { + final preLogin = await context.loaderWithErrorDialog( + () => _callLinkingPreLoginApi( + context: context, + ref: ref, + username: emailCtl.text, + password: pwCtl.text, + ), + ); + final success = preLogin['success'] == true; + final apiUserId = preLogin['userId']?.toString(); + final apiToken = preLogin['token']?.toString(); + final apiMessage = preLogin['message']?.toString(); + if (success && apiUserId != null && apiUserId.isNotEmpty) { + userId = apiUserId; + if (apiToken != null && apiToken.isNotEmpty) { + authToken = apiToken; + } + } else { + // Hard stop on linking API failure: do not continue login flow. + PocketBaseService.authStore.clear(); + if (context.mounted) { + await context.simpleDialog( title: context.translate( 'Incorrect Credentials', 'أوراق غير صحيحة', ), - content: context.translate( - 'Your email or password is invalid. Please try again.', - 'بريدك الإلكتروني أو كلمة المرور غير صالحة. حاول مرة اخرى.', - ), + content: (apiMessage != null && apiMessage.isNotEmpty) + ? apiMessage + : context.translate( + 'Your email or password is invalid. Please try again.', + 'بريدك الإلكتروني أو كلمة المرور غير صالحة. حاول مرة اخرى.', + ), ); } - // if (error == LoginError.emailAddressNotVerified) { - // return context.simpleDialog( - // title: context.translate( - // 'Verification Error', - // 'خطأ التحقق', - // ), - // content: context.translate( - // '${emailCtl.text} is not a verified email address. Please check your email for a verification link.', - // '${emailCtl.text} ليس عنوان بريد إلكتروني تم التحقق منه. يرجى التحقق من بريدك الإلكتروني للحصول على رابط التحقق.', - // ), - // extraAction: ElevatedButton( - // onPressed: () async { - // Navigator.of( - // context, - // rootNavigator: true, - // ).pop(); - // await context.loaderWithErrorDialog( - // () => ref - // .read(authUseCaseProvider.notifier) - // .requestVerificationEmail(emailCtl.text), - // ); - // if (!context.mounted) return; - // context.simpleDialog( - // title: 'Email Re-sent', - // content: - // 'We\'ve sent you the verification email at ${emailCtl.text} again.', - // ); - // }, - // child: Text( - // context.translate( - // 'I did not receive an email', - // 'لم أتلق بريدًا إلكترونيًا', - // ), - // ), - // ), - // ); - // } - return context.simpleDialog(); - }, - ); + return; + } + } + + // Extra safeguard: if linking flow didn't produce a user id, stop here. + if (cameFromLinking && userId.isEmpty) { + return; + } + + if (!cameFromLinking) { + final session = await context.loaderWithErrorDialog( + () => ref + .read( + authUseCaseProvider.notifier, + ) + .login( + email, + pwCtl.text, + ), + errorDialogBuilder: ( + error, [ + StackTrace? stackTrace, + ]) { + if (error == LoginError.invalidEmailPw) { + return context.simpleDialog( + title: context.translate( + 'Incorrect Credentials', + 'أوراق غير صحيحة', + ), + content: context.translate( + 'Your email or password is invalid. Please try again.', + 'بريدك الإلكتروني أو كلمة المرور غير صالحة. حاول مرة اخرى.', + ), + ); + } + return context.simpleDialog(); + }, + ); + if (!context.mounted || session == null) return; + userId = session.id; + authToken = PocketBaseService.authStore.token; + } + + // YES path: merge normal account into UAE PASS OAuth record, then drop normal user. + // Skip FCSC login_success / device token on the temporary normal session. + // if (cameFromLinking) { + // await _mergeUaepassIntoNormalUser( + // normalUserId: userId, + // ref: ref, + // context: context, + // ); + // return; + // } - if (!context.mounted || session == null) return; - final userId = session.id; if (userId.isNotEmpty) { print('EMAIL - $email '); print('PASSWORD - ${pwCtl.text}'); @@ -1982,7 +2415,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ print("LOginBtn"); final count = jsonDecode(response.body); loginCount = - count["login_count"]; // Directly assign it as an int + count["login_count"]; // Directly assign it as an int print(loginCount); } else { print( @@ -1993,21 +2426,10 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ } await saveUserId(userId, loginCount); - await getDeviceToken( - context, userId, ref, PocketBaseService.authStore.token); + await getDeviceToken(context, userId, ref, authToken); await initializeTheme(context, ref, userId); } try { - // If coming from UAE PASS linking (YES), merge the temporary UAEPASS - // user row into this normal logged-in user. - if (cameFromLinking) { - await _mergeUaepassIntoNormalUser( - normalUserId: userId, - ref: ref, - context: context, - ); - } - final bool isProfileComplete = await profileStatus(userId, ref); print("Is Profile Complete: $isProfileComplete"); @@ -2107,8 +2529,8 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ children: [ if (cameFromLinking) ...[ Text( - context.translate('Link account with UAE PASS', - 'ربط الحساب مع بطاقة الهوية الإماراتية'), + context.translate('Link above account with UAE PASS', + 'ربط فوق الحساب مع بطاقة الهوية الإماراتية'), style: TextStyle( fontFamily: context.translate( 'Roboto', @@ -2122,7 +2544,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ ] else ...[ Text( context.translate( - 'Login', + 'Sign in', 'تسجيل الدخول', ), style: TextStyle( @@ -2307,7 +2729,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ 10.verticalSpace, Text( context.translate( - 'Please login to access \n UAE’s key official statistics', + 'Please Sign in to access \n UAE’s key official statistics', 'يرجى تسجيل الدخول للوصول إلى الإحصاءات الرسمية الرئيسية لدولة الإمارات العربية المتحدة', ), textAlign: TextAlign.center, @@ -2496,44 +2918,56 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ ), ), ); - final signInWithUAEPassBtn = SizedBox( - width: double.infinity, - child: OutlinedButton( - onPressed: () async { - await oAuthGoogleAndAppleLogin(context, ref, 'oidc'); - }, - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFF000000)), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + final uaePassButtonAsset = locale?.languageCode == 'ar' + ? (isDarkTheme + ? MiscIconAssetPath.signInWithUAEPassArDark + : MiscIconAssetPath.signInWithUAEPassArLight) + : (isDarkTheme + ? MiscIconAssetPath.signInWithUAEPassEnDark + : MiscIconAssetPath.signInWithUAEPassEnLight); + final signInWithUAEPassBtn = Semantics( + button: true, + // label: AppLocalizations.of(context)!.uaepass_sign_in, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () async { + await context.loaderWithErrorDialog( + () async { + final authData = await signInWithUaePass( + context, + ref, + isUaePassFlowActive, + ); + debugPrint('UAE PASS sign-in button received: $authData'); + if (authData == null || !context.mounted) return; + await _continueAfterUaePassRecordAuth(context, ref, authData); + }, + errorDialogBuilder: (error, [StackTrace? stackTrace]) { + if (error is UserVisibleApiException) { + return context.simpleDialog(content: error.message); + } + if (error == LoginError.invalidEmailPw) { + return context.simpleDialog( + title: context.translate( + 'Incorrect Credentials', + 'أوراق غير صحيحة', + ), + content: context.translate( + 'Your email or password is invalid. Please try again.', + 'بريدك الإلكتروني أو كلمة المرور غير صالحة. حاول مرة اخرى.', + ), + ); + } + return context.simpleDialog(); + }, + ); + }, + child: SvgPicture.asset( + uaePassButtonAsset, + excludeFromSemantics: true, ), - padding: const EdgeInsets.symmetric(vertical: 16.5), - backgroundColor: Colors.white, - foregroundColor: Colors.black, - textStyle: TextStyle( - fontFamily: context.translate('Roboto', 'NotoKufi'), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - MiscIconAssetPath - .signInWithUAEPass, // replace with your Google icon asset - height: 20, - ), - const SizedBox(width: 10), - Text( - AppLocalizations.of(context)!.uaepass_sign_in, - textAlign: TextAlign.center, - style: TextStyle( - color: Color(0xFF000000), - fontWeight: FontWeight.w600, - ), - ), - ], ), ), ); @@ -2547,7 +2981,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ ), ); final listViewHorizontalPadding = - screenWidth < 700 ? 30 : 30 + (screenWidth - 700) / 2; + screenWidth < 700 ? 30 : 30 + (screenWidth - 700) / 2; final scaffoldBody = SingleChildScrollView( child: Column(children: [ Padding( @@ -2597,7 +3031,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ Padding( padding: EdgeInsets.symmetric( horizontal: - isTablet ? 80 : 24, // ✅ responsive horizontal space + isTablet ? 80 : 24, // ✅ responsive horizontal space ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -2659,6 +3093,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ canPop: false, // Allow back navigation only if not login screen onPopInvokedWithResult: (didPop, result) { if (didPop) return; + if (isUaePassFlowActive.value) return; SystemNavigator.pop(); // _showExitConfirmation(context); // Show exit confirmation dialog }, @@ -2667,8 +3102,7 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ body: SafeArea( child: isInit.value ? scaffoldBody - : const CircularProgressIndicator() - ), + : const CircularProgressIndicator()), ), ); } @@ -2732,70 +3166,28 @@ class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver{ // ), // ); // } - } -/// ======================================================= -/// APP LIFECYCLE OBSERVER (AUTO-CLOSE LOGIC) -/// ======================================================= -class _OAuthLifecycleObserver extends WidgetsBindingObserver { - final PocketBase pb; - final BuildContext context; - - _OAuthLifecycleObserver({ - required this.pb, - required this.context, - }); - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - _handleResume(); - } - } - - void _handleResume() { - if (pb.authStore.isValid) { - debugPrint('✅ PocketBase OAuth login detected'); - - print("✅ OAuth success detected on resume"); - - final userDetails = pb.authStore.model; - final token = pb.authStore.token; - - print(userDetails); - - if (userDetails == null || token.isEmpty) { - print("❌ Invalid authStore data"); - return; - } - - if (context.mounted) { - context.go('/myhomepage'); - } - } - } -} - -class OAuthWebView extends StatelessWidget { +class OAuthWebViewApple extends StatelessWidget { final String authUrl; final bool useUaePassDeepLink; - const OAuthWebView({ + const OAuthWebViewApple({ required this.authUrl, this.useUaePassDeepLink = false, }); static const _uaepassStagingScheme = 'uaepassstg'; + static const _uaepassQaScheme = 'uaepassqa'; static const _uaepassProdScheme = 'uaepass'; static const _appCallbackScheme = 'ae.gov.fcsc.stats'; static const _resumePath = 'resume_authn'; Future _closeIfOAuthCompleted( - BuildContext context, - InAppWebViewController controller, - WebUri? url, - ) async { + BuildContext context, + InAppWebViewController controller, + WebUri? url, + ) async { if (!context.mounted) return; final s = (url?.toString() ?? '').toLowerCase(); @@ -2828,8 +3220,7 @@ class OAuthWebView extends StatelessWidget { (html.contains('you can close this window') || html.contains('close this window')); final newSuccess = html.contains('authentication successful') && - (html.contains('proceed') || - html.contains('return to uae stats app')); + (html.contains('proceed') || html.contains('return to uae stats app')); if (legacySuccess || newSuccess) { if (!useUaePassDeepLink && !authIsReady) return; @@ -2840,9 +3231,9 @@ class OAuthWebView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Sign in with UAE PASS'), - ), + // appBar: AppBar( + // title: const Text('Sign in with UAE PASS'), + // ), body: InAppWebView( initialUrlRequest: URLRequest(url: WebUri(authUrl)), initialSettings: InAppWebViewSettings( @@ -2856,10 +3247,13 @@ class OAuthWebView extends StatelessWidget { final full = uri.toString(); // If callback deep link is about to open, forward to OS and close WebView. - if (full.toLowerCase().startsWith('$_appCallbackScheme://$_resumePath')) { + if (full + .toLowerCase() + .startsWith('$_appCallbackScheme://$_resumePath')) { final callbackUri = Uri.tryParse(full); if (callbackUri != null) { - await launchUrl(callbackUri, mode: LaunchMode.externalApplication); + await launchUrl(callbackUri, + mode: LaunchMode.externalApplication); } if (context.mounted) { await Navigator.of(context).maybePop(); @@ -2871,9 +3265,11 @@ class OAuthWebView extends StatelessWidget { return NavigationActionPolicy.ALLOW; } - // Match uaepass:// or uaepassstg:// (scheme can be missing on some platforms) - if (!full.toLowerCase().startsWith('$_uaepassProdScheme://') && - !full.toLowerCase().startsWith('$_uaepassStagingScheme://')) { + // Match UAE PASS deep-link schemes from provider response. + final lowerFull = full.toLowerCase(); + if (!lowerFull.startsWith('$_uaepassProdScheme://') && + !lowerFull.startsWith('$_uaepassQaScheme://') && + !lowerFull.startsWith('$_uaepassStagingScheme://')) { return NavigationActionPolicy.ALLOW; } final parsed = Uri.tryParse(full); @@ -2888,20 +3284,24 @@ class OAuthWebView extends StatelessWidget { final state = UaePassOAuthState.instance; state.savedSuccessUrl = successUrl; state.savedFailureUrl = failureUrl ?? successUrl; - final encodedSuccess = - Uri.encodeComponent(successUrl); - final encodedFailure = - Uri.encodeComponent(state.savedFailureUrl!); + final encodedSuccess = Uri.encodeComponent(successUrl); + final encodedFailure = Uri.encodeComponent(state.savedFailureUrl!); final rewrittenSuccess = '$_appCallbackScheme://$_resumePath?url=$encodedSuccess'; final rewrittenFailure = '$_appCallbackScheme://$_resumePath?url=$encodedFailure'; - final successKey = q.containsKey('successurl') ? 'successurl' : 'successURL'; - final failureKey = q.containsKey('failureurl') ? 'failureurl' : 'failureURL'; - final rewrittenQuery = Map.from(parsed.queryParameters) - ..[successKey] = rewrittenSuccess - ..[failureKey] = rewrittenFailure; + final successKey = + q.containsKey('successurl') ? 'successurl' : 'successURL'; + final failureKey = + q.containsKey('failureurl') ? 'failureurl' : 'failureURL'; + final rewrittenQuery = + Map.from(parsed.queryParameters) + ..[successKey] = rewrittenSuccess + ..[failureKey] = rewrittenFailure; final rewritten = parsed.replace( + // Per UAE PASS app-app staging flow, force staging scheme when launching app. + // (Equivalent to documented uaepass -> uaepassstg/qa conversion.) + scheme: _uaepassStagingScheme, queryParameters: rewrittenQuery, ); // Return CANCEL immediately so the WebView does not load uaepass:// (ERR_UNKNOWN_URL_SCHEME). @@ -2927,7 +3327,6 @@ class OAuthWebView extends StatelessWidget { } } - // abstract class _LocalAuthRepo { // static const _key = 'session'; // static const _storage = FlutterSecureStorage(); @@ -2952,4 +3351,4 @@ class OAuthWebView extends StatelessWidget { // ); // // static Future clear() => _storage.delete(key: _key); -// } \ No newline at end of file +// } diff --git a/lib/presentation/routes/auth_routes/oauth_webview.dart b/lib/presentation/routes/auth_routes/oauth_webview.dart new file mode 100644 index 00000000..442978de --- /dev/null +++ b/lib/presentation/routes/auth_routes/oauth_webview.dart @@ -0,0 +1,586 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter/foundation.dart'; +import 'package:uae_stat/config/api_config.dart'; +import 'package:uae_stat/config/uae_pass_oauth_state.dart'; +import 'package:uae_stat/infrastructure/services/pocketbase_service.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Handles the UAE PASS Mobile-to-Mobile (M2M) OAuth flow entirely inside a +/// persistent WebView, following the 8-step UAE PASS mobile integration spec. +class OAuthWebView extends StatefulWidget { + final String authUrl; + + /// true → UAE PASS app IS installed; use M2M deep-link rewrite flow. + /// false → UAE PASS app is NOT installed; use web-form (level:low) fallback. + final bool useUaePassDeepLink; + + /// When the WebView reaches PocketBase `/api/oauth2-redirect?code=...`, call this + /// so mobile can run [RecordService.authWithOAuth2Code] without relying on SSE + /// `@oauth2` delivery (which breaks after app-switch / SSE reconnect). + final Future Function(String code, String? state)? onPocketBaseRedirect; + + /// IdP cancelled or error: `/api/oauth2-redirect?error=access_denied&...` (no `code`). + final Future Function(String errorCode, String? errorDescription)? + onPocketBaseOAuthError; + + const OAuthWebView({ + super.key, + required this.authUrl, + this.useUaePassDeepLink = false, + this.onPocketBaseRedirect, + this.onPocketBaseOAuthError, + }); + + // ── Schemes ────────────────────────────────────────────────────────────── + /// Your app's registered URI scheme (must match AndroidManifest + Info.plist). + static const _appScheme = 'ae.gov.fcsc.stats'; + static const _resumeHost = 'resume_authn'; + + /// UAE PASS app URI schemes (staging / qa / production). + static const _uaepassStgScheme = 'uaepassstg'; + static const _uaepassQaScheme = 'uaepassqa'; + static const _uaepassProdScheme = 'uaepass'; + + @override + State createState() => _OAuthWebViewState(); +} + +class _OAuthWebViewState extends State + with WidgetsBindingObserver { + InAppWebViewController? _controller; + bool _isPageLoading = true; + BuildContext? _appLoaderContext; + bool _isAppLoaderVisible = false; + bool _didDismissInitialAppLoader = false; + + /// Set to true the moment we launch the UAE PASS app (step 5). + /// Reset when we return from it (step 6 → 7). + bool _waitingForUaePassReturn = false; + + /// Prevents parallel [auth-with-oauth2] calls: OAuth `code` is single-use — + /// duplicate POSTs cause PocketBase `invalid_grant` / inactive authorization code. + bool _pocketBaseExchangeInFlight = false; + + /// Set after a successful exchange so repeat navigations (loadStart/loadStop/history) skip. + String? _pocketBaseExchangeSucceededForCode; + + /// Runs [onPocketBaseRedirect] once per auth `code`. PocketBase may still render an + /// "Auth failed" HTML page when SSE notify fails even though [auth-with-oauth2] + /// succeeds from the app — intercept navigation in [shouldOverrideUrlLoading] + /// so that HTML is never loaded. + Future _exchangeAuthorizationCodeIfPresent(String rawUrl) async { + if (widget.onPocketBaseRedirect == null) return; + + final urlStr = rawUrl.toLowerCase(); + if (!urlStr.contains('/api/oauth2-redirect')) return; + + final code = _oauthCodeFromUrl(rawUrl); + final state = _oauthStateFromUrl(rawUrl); + if (code == null || code.isEmpty) return; + + if (_pocketBaseExchangeSucceededForCode == code) return; + if (_pocketBaseExchangeInFlight) { + debugPrint( + 'PocketBase OAuth: duplicate event while exchanging — skipped', + ); + return; + } + + UaePassOAuthState.instance.oauthCode = code; + UaePassOAuthState.instance.oauthState = state; + debugPrint('UAE PASS OAuth code captured from URL'); + + _pocketBaseExchangeInFlight = true; + try { + await widget.onPocketBaseRedirect!(code, state); + _pocketBaseExchangeSucceededForCode = code; + } catch (e, st) { + debugPrint('PocketBase OAuth exchange failed: $e\n$st'); + } finally { + _pocketBaseExchangeInFlight = false; + } + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance.addPostFrameCallback((_) { + _showInitialAppLoader(); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// Step 6 + 7 — when the app comes back to the foreground after the user + /// authenticated in UAE PASS, load the saved successURL back in the WebView. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + if (!_waitingForUaePassReturn) return; + _waitingForUaePassReturn = false; + + final savedSuccess = UaePassOAuthState.instance.savedSuccessUrl; + if (savedSuccess == null || savedSuccess.isEmpty) return; + + // Step 7: reload the original successURL so the auth server can complete + // the OAuth flow and eventually hit the redirect URI (step 8). + SchedulerBinding.instance.addPostFrameCallback((_) { + _controller?.loadUrl( + urlRequest: URLRequest(url: WebUri(savedSuccess)), + ); + }); + } + + // ── OAuth completion detector (step 8) ─────────────────────────────────── + + String? _oauthCodeFromUrl(String rawUrl) { + final u = Uri.tryParse(rawUrl); + if (u == null) return null; + final direct = u.queryParameters['code']; + if (direct != null && direct.isNotEmpty) return direct; + final frag = u.fragment; + if (frag.isEmpty) return null; + final qIdx = frag.indexOf('?'); + if (qIdx >= 0) { + final inner = Uri.tryParse('https://x/?${frag.substring(qIdx + 1)}'); + final c = inner?.queryParameters['code']; + if (c != null && c.isNotEmpty) return c; + } + return null; + } + + String? _oauthStateFromUrl(String rawUrl) { + final u = Uri.tryParse(rawUrl); + if (u == null) return null; + final direct = u.queryParameters['state']; + if (direct != null && direct.isNotEmpty) return direct; + final frag = u.fragment; + final qIdx = frag.indexOf('?'); + if (qIdx >= 0) { + final inner = Uri.tryParse('https://x/?${frag.substring(qIdx + 1)}'); + return inner?.queryParameters['state']; + } + return null; + } + + String? _queryParamCaseInsensitive(Uri u, String nameLower) { + for (final e in u.queryParameters.entries) { + if (e.key.toLowerCase() == nameLower) { + final v = e.value.trim(); + return v.isEmpty ? null : v; + } + } + return null; + } + + String? _oauthErrorFromUrl(String rawUrl) { + final u = Uri.tryParse(rawUrl); + if (u == null) return null; + var err = _queryParamCaseInsensitive(u, 'error'); + if (err != null) return err; + final frag = u.fragment; + final qIdx = frag.indexOf('?'); + if (qIdx >= 0) { + final inner = Uri.tryParse('https://x/?${frag.substring(qIdx + 1)}'); + if (inner != null) { + err = _queryParamCaseInsensitive(inner, 'error'); + if (err != null) return err; + } + } + return null; + } + + String? _oauthErrorDescriptionFromUrl(String rawUrl) { + final u = Uri.tryParse(rawUrl); + if (u == null) return null; + var d = _queryParamCaseInsensitive(u, 'error_description'); + if (d != null) return d; + final frag = u.fragment; + final qIdx = frag.indexOf('?'); + if (qIdx >= 0) { + final inner = Uri.tryParse('https://x/?${frag.substring(qIdx + 1)}'); + if (inner != null) { + d = _queryParamCaseInsensitive(inner, 'error_description'); + if (d != null) return d; + } + } + return null; + } + + Future _notifyPocketBaseOAuthErrorIfPresent(String rawUrl) async { + if (widget.onPocketBaseOAuthError == null) return; + final err = _oauthErrorFromUrl(rawUrl); + if (err == null || err.isEmpty) return; + final desc = _oauthErrorDescriptionFromUrl(rawUrl); + debugPrint('UAE PASS OAuth redirect error=$err description=$desc'); + await widget.onPocketBaseOAuthError!(err, desc); + } + + Future _checkOAuthComplete( + InAppWebViewController controller, + WebUri? url, + ) async { + if (!mounted) return; + + final rawUrl = url?.toString() ?? ''; + final urlStr = rawUrl.toLowerCase(); + final authReady = PocketBaseService.authStore.isValid; + final code = _oauthCodeFromUrl(rawUrl); + if (urlStr.isNotEmpty) { + debugPrint('UAE PASS WebView URL: $urlStr'); + debugPrint('UAE PASS authStore.isValid: $authReady'); + } + + // PocketBase redirect URI — OAuth error (user cancel / IdP error), no exchange. + if (urlStr.contains('/api/oauth2-redirect')) { + final oauthErr = _oauthErrorFromUrl(rawUrl); + if (oauthErr != null && + oauthErr.isNotEmpty && + (code == null || code.isEmpty)) { + await _notifyPocketBaseOAuthErrorIfPresent(rawUrl); + await _finish(); + return; + } + } + + // PocketBase redirect URI — code is in the URL (HTTP or hash-router variants). + if (urlStr.contains('/api/oauth2-redirect') && + code != null && + code.isNotEmpty) { + await _exchangeAuthorizationCodeIfPresent(rawUrl); + await _finish(); + return; + } + + // Success page URL patterns. + if (urlStr.contains('oauth2-redirect-success') || + urlStr.contains('oauth2_redirect_success')) { + // Do not close WebView on generic success page until PocketBase auth is valid. + // On mobile, closing too early can prevent authWithOAuth2 from completing. + if (!authReady) return; + await _finish(); + return; + } + + // Success page title patterns. + final title = ((await controller.getTitle()) ?? '').toLowerCase(); + if (title.contains('auth success') || + title.contains('auth completed') || + title.contains('authentication successful')) { + // Do not close on title-only success without valid auth state. + if (!authReady) return; + await _finish(); + return; + } + + // Legacy + new success page HTML patterns. + final html = ((await controller.getHtml()) ?? '').toLowerCase(); + final legacySuccess = html.contains('auth completed') && + (html.contains('you can close this window') || + html.contains('close this window')); + final newSuccess = html.contains('authentication successful') && + (html.contains('proceed') || html.contains('return to uae stats app')); + + if (legacySuccess || newSuccess) { + // Do not close on HTML-only success without valid auth state. + if (!authReady) return; + await _finish(); + } + } + + Future _showInitialAppLoader() async { + if (!mounted || _isAppLoaderVisible || _didDismissInitialAppLoader) return; + final ctxWaiter = Completer(); + _isAppLoaderVisible = true; + showDialog( + barrierDismissible: false, + context: context, + builder: (loaderContext) { + if (!ctxWaiter.isCompleted) ctxWaiter.complete(loaderContext); + return const Dialog( + child: LinearProgressIndicator(), + ); + }, + ); + _appLoaderContext = await ctxWaiter.future; + } + + Future _dismissInitialAppLoader() async { + if (!_isAppLoaderVisible) return; + _didDismissInitialAppLoader = true; + _isAppLoaderVisible = false; + final loaderContext = _appLoaderContext; + _appLoaderContext = null; + if (loaderContext != null && loaderContext.mounted) { + Navigator.of(loaderContext, rootNavigator: true).pop(); + } + } + + Future _finish() async { + await _dismissInitialAppLoader(); + if (mounted) { + await Navigator.of(context).maybePop(); + } + } + + void _setPageLoading(bool value) { + if (!mounted || _isPageLoading == value) return; + setState(() { + _isPageLoading = value; + }); + } + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Stack( + children: [ + Offstage( + offstage: _isPageLoading, + child: InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(widget.authUrl)), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + domStorageEnabled: true, + useShouldOverrideUrlLoading: true, + transparentBackground: false, + ), + onWebViewCreated: (controller) => _controller = controller, + onLoadStart: (c, url) { + _setPageLoading(true); + _checkOAuthComplete(c, url); + }, + onLoadStop: (c, url) { + _checkOAuthComplete(c, url); + _setPageLoading(false); + _dismissInitialAppLoader(); + }, + onProgressChanged: (controller, progress) { + if (progress >= 100) { + _setPageLoading(false); + _dismissInitialAppLoader(); + } + }, + + // ── Step 3, 4, 5 + App-callback handler ────────────────────────── + shouldOverrideUrlLoading: (controller, action) async { + final uri = action.request.url; + if (uri == null) return NavigationActionPolicy.ALLOW; + final full = uri.toString(); + final fullLower = full.toLowerCase(); + + // PocketBase OAuth redirect: handle error (e.g. access_denied) before code path. + if (fullLower.contains('/api/oauth2-redirect')) { + final oauthErr = _oauthErrorFromUrl(full); + final redirectCode = _oauthCodeFromUrl(full); + if (oauthErr != null && + oauthErr.isNotEmpty && + (redirectCode == null || redirectCode.isEmpty)) { + await _notifyPocketBaseOAuthErrorIfPresent(full); + if (mounted) await _finish(); + return NavigationActionPolicy.CANCEL; + } + } + + // Cancel loading PocketBase redirect HTML — server often shows "Auth failed" + // when realtime notify fails even if app-side [auth-with-oauth2] succeeds. + if (widget.onPocketBaseRedirect != null && + fullLower.contains('/api/oauth2-redirect')) { + final redirectCode = _oauthCodeFromUrl(full); + if (redirectCode != null && redirectCode.isNotEmpty) { + await _exchangeAuthorizationCodeIfPresent(full); + if (mounted) await _finish(); + return NavigationActionPolicy.CANCEL; + } + } + + bool isUaePassDeepLink(String lower) { + return lower.startsWith( + '${OAuthWebView._uaepassProdScheme}://') || + lower.startsWith( + '${OAuthWebView._uaepassQaScheme}://') || + lower + .startsWith('${OAuthWebView._uaepassStgScheme}://'); + } + + /// UAE PASS mobile deep links must never load inside WebView — always + /// open the native app (otherwise Android shows ERR_UNKNOWN_URL_SCHEME). + Future launchUaePassExternally( + String launchTarget) async { + var parsedLaunch = Uri.tryParse(launchTarget); + if (parsedLaunch == null) return; + // IdP may return `uaepass://` (production) even for staging users; open + // the staging app when [uaePassUseStagingEnvironment] is true. + if (uaePassUseStagingEnvironment) { + parsedLaunch = parsedLaunch.replace( + scheme: OAuthWebView._uaepassStgScheme, + ); + debugPrint( + 'UAE PASS: using staging app scheme: $parsedLaunch'); + } + _waitingForUaePassReturn = true; + final launched = await launchUrl( + parsedLaunch, + mode: LaunchMode.externalApplication, + ); + if (!launched) _waitingForUaePassReturn = false; + } + + // ── Step 6: Your app's scheme returns from UAE PASS ───────────── + // This fires when the OS routes yourapp://resume_authn?url=... + // back into the WebView (Android only — iOS goes via AppDelegate). + if (fullLower.startsWith( + '${OAuthWebView._appScheme}://${OAuthWebView._resumeHost}')) { + final parsed = Uri.tryParse(full); + final origUrl = parsed?.queryParameters['url']; + if (origUrl != null && origUrl.isNotEmpty) { + final decoded = Uri.decodeComponent(origUrl); + final decodedUri = Uri.tryParse(decoded); + final code = decodedUri?.queryParameters['code']; + final state = decodedUri?.queryParameters['state']; + if (code != null && code.isNotEmpty) { + UaePassOAuthState.instance.oauthCode = code; + UaePassOAuthState.instance.oauthState = state; + debugPrint( + 'UAE PASS OAuth code captured from resume_authn URL'); + } + // Step 7: load the original success/failure URL in WebView. + controller.loadUrl( + urlRequest: URLRequest(url: WebUri(decoded)), + ); + } + return NavigationActionPolicy.CANCEL; + } + + // ── Steps 3 + 4 + 5: UAE PASS app schemes (live / stg / qa) ───── + // Must never load inside WebView — avoids net::ERR_UNKNOWN_URL_SCHEME. + // Runs before any useUaePassDeepLink gate so prod `uaepass://` is always + // intercepted (install detection may only have checked staging). + if (!isUaePassDeepLink(fullLower)) { + return NavigationActionPolicy.ALLOW; + } + + final parsed = Uri.tryParse(full); + if (parsed == null) return NavigationActionPolicy.CANCEL; + + String? queryValueCaseInsensitive(Uri u, String nameLower) { + for (final e in u.queryParameters.entries) { + if (e.key.toLowerCase() == nameLower) { + return e.value.isEmpty ? null : e.value; + } + } + return null; + } + + String queryKeyCaseInsensitive(Uri u, String nameLower) { + for (final k in u.queryParameters.keys) { + if (k.toLowerCase() == nameLower) return k; + } + return nameLower; + } + + final successUrl = + queryValueCaseInsensitive(parsed, 'successurl'); + final failureUrl = + queryValueCaseInsensitive(parsed, 'failureurl'); + + // No rewrite targets — still must not load uaepass:// in WebView. + if (successUrl == null || successUrl.isEmpty) { + debugPrint( + 'UAE PASS: launching native app (no successURL to rewrite): $full', + ); + await launchUaePassExternally(full); + return NavigationActionPolicy.CANCEL; + } + + // Step 4a: save originals so step 7 can restore them. + final oauthState = UaePassOAuthState.instance; + oauthState.savedSuccessUrl = successUrl; + oauthState.savedFailureUrl = failureUrl ?? successUrl; + + // Step 4b: rewrite successURL / failureURL to use your app scheme. + final rewrittenSuccess = + '${OAuthWebView._appScheme}://${OAuthWebView._resumeHost}' + '?url=${Uri.encodeComponent(successUrl)}'; + final rewrittenFailure = + '${OAuthWebView._appScheme}://${OAuthWebView._resumeHost}' + '?url=${Uri.encodeComponent(oauthState.savedFailureUrl!)}'; + + final successKey = + queryKeyCaseInsensitive(parsed, 'successurl'); + final failureKey = + queryKeyCaseInsensitive(parsed, 'failureurl'); + + final rewrittenParams = Map.from( + parsed.queryParameters, + ); + rewrittenParams[successKey] = rewrittenSuccess; + rewrittenParams[failureKey] = rewrittenFailure; + + // Staging build: always open UAE PASS Staging (`uaepassstg://`). Live + // build: keep IdP scheme (prod / QA / stg) as returned. + final useScheme = uaePassUseStagingEnvironment + ? OAuthWebView._uaepassStgScheme + : (() { + final schemeLower = parsed.scheme.toLowerCase(); + if (schemeLower == OAuthWebView._uaepassProdScheme || + schemeLower == OAuthWebView._uaepassQaScheme || + schemeLower == OAuthWebView._uaepassStgScheme) { + return parsed.scheme; + } + return OAuthWebView._uaepassProdScheme; + })(); + + final launchUri = parsed.replace( + scheme: useScheme, + queryParameters: rewrittenParams, + ); + + debugPrint('UAE PASS: launching native app: $launchUri'); + await launchUaePassExternally(launchUri.toString()); + + return NavigationActionPolicy.CANCEL; + }, + + // ── Step 8: monitor URLs for OAuth completion ──────────────────── + onUpdateVisitedHistory: (c, url, _) => + _checkOAuthComplete(c, url), + ), + ), + if (_isPageLoading) + Positioned.fill( + child: ColoredBox( + color: Colors.white, + child: Center( + child: SizedBox( + width: 180, + child: LinearProgressIndicator( + color: const Color(0xFFB68A34), + backgroundColor: const Color(0xFFE8E0CC), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/routes/auth_routes/simple_oAuth.dart b/lib/presentation/routes/auth_routes/simple_oAuth.dart new file mode 100644 index 00000000..8d35ea2b --- /dev/null +++ b/lib/presentation/routes/auth_routes/simple_oAuth.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter/foundation.dart'; +import 'package:uae_stat/infrastructure/services/pocketbase_service.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:uae_stat/config/uae_pass_oauth_state.dart'; + +/// Handles the UAE PASS Mobile-to-Mobile (M2M) OAuth flow entirely inside a +/// persistent WebView, following the 8-step UAE PASS mobile integration spec. +class OAuthWebViewSimple extends StatefulWidget { + final String authUrl; + + /// true → UAE PASS app IS installed; use M2M deep-link rewrite flow. + /// false → UAE PASS app is NOT installed; use web-form (level:low) fallback. + final bool useUaePassDeepLink; + + const OAuthWebViewSimple({ + super.key, + required this.authUrl, + this.useUaePassDeepLink = false, + }); + + // ── Schemes ────────────────────────────────────────────────────────────── + /// Your app's registered URI scheme (must match AndroidManifest + Info.plist). + static const _appScheme = 'ae.gov.fcsc.stats'; + static const _resumeHost = 'resume_authn'; + + /// UAE PASS app URI schemes (staging / qa / production). + static const _uaepassStgScheme = 'uaepassstg'; + static const _uaepassQaScheme = 'uaepassqa'; + static const _uaepassProdScheme = 'uaepass'; + + @override + State createState() => _OAuthWebViewState(); +} + +class _OAuthWebViewState extends State + with WidgetsBindingObserver { + InAppWebViewController? _controller; + + /// Set to true the moment we launch the UAE PASS app (step 5). + /// Reset when we return from it (step 6 → 7). + bool _waitingForUaePassReturn = false; + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// Step 6 + 7 — when the app comes back to the foreground after the user + /// authenticated in UAE PASS, load the saved successURL back in the WebView. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (!mounted) return; // 🔥 ADD THIS + + if (state != AppLifecycleState.resumed) return; + if (!_waitingForUaePassReturn) return; + + _waitingForUaePassReturn = false; + + final savedSuccess = UaePassOAuthState.instance.savedSuccessUrl; + if (savedSuccess == null || savedSuccess.isEmpty) return; + + _controller?.loadUrl( + urlRequest: URLRequest(url: WebUri(savedSuccess)), + ); + } + + // ── OAuth completion detector (step 8) ─────────────────────────────────── + Future _checkOAuthComplete( + InAppWebViewController controller, + WebUri? url, + ) async { + if (!mounted || url == null) return; + + final urlStr = url.toString(); + + debugPrint('OAuth URL: $urlStr'); + + /// ✅ Capture code + final uri = Uri.tryParse(urlStr); + final code = uri?.queryParameters['code']; + + if (code != null && code.isNotEmpty) { + UaePassOAuthState.instance.oauthCode = code; + debugPrint('OAuth code captured'); + } + + /// ✅ Finish only when redirect URL hits + if (urlStr.contains('/api/oauth2-redirect') && urlStr.contains('code=')) { + _finish(); + } + } + + void _finish() { + if (!mounted) return; + + if (Navigator.canPop(context)) { + Navigator.of(context).pop(); + } + } + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(widget.authUrl)), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + domStorageEnabled: true, + useShouldOverrideUrlLoading: true, + ), + onWebViewCreated: (controller) => _controller = controller, + + // ── Step 3, 4, 5 + App-callback handler ────────────────────────── + shouldOverrideUrlLoading: (controller, action) async { + final uri = action.request.url; + if (uri == null) return NavigationActionPolicy.ALLOW; + + final urlStr = uri.toString().toLowerCase(); + + /// ✅ Handle app return (resume_authn) + if (urlStr.startsWith( + '${OAuthWebViewSimple._appScheme}://${OAuthWebViewSimple._resumeHost}')) { + + final parsed = Uri.tryParse(uri.toString()); + final origUrl = parsed?.queryParameters['url']; + + if (origUrl != null) { + final decoded = Uri.decodeComponent(origUrl); + controller.loadUrl( + urlRequest: URLRequest(url: WebUri(decoded)), + ); + } + + return NavigationActionPolicy.CANCEL; + } + + /// ❌ If not using deep link → allow + if (!widget.useUaePassDeepLink) { + return NavigationActionPolicy.ALLOW; + } + + /// ✅ Handle UAE PASS schemes + if (uri.scheme == 'uaepass' || + uri.scheme == 'uaepassstg' || + uri.scheme == 'uaepassqa') { + + final parsed = Uri.tryParse(uri.toString()); + if (parsed == null) return NavigationActionPolicy.ALLOW; + + final q = parsed.queryParameters; + final successUrl = q['successURL'] ?? q['successurl']; + + if (successUrl == null) { + return NavigationActionPolicy.ALLOW; + } + + /// Save URLs + final state = UaePassOAuthState.instance; + state.savedSuccessUrl = successUrl; + state.savedFailureUrl = q['failureURL'] ?? successUrl; + + /// Rewrite to app scheme + final rewritten = '${OAuthWebViewSimple._appScheme}://${OAuthWebViewSimple._resumeHost}' + '?url=${Uri.encodeComponent(successUrl)}'; + + final newParams = Map.from(q) + ..['successURL'] = rewritten + ..['failureURL'] = rewritten; + + final launchUri = parsed.replace( + scheme: OAuthWebViewSimple._uaepassStgScheme, // staging + queryParameters: newParams, + ); + + _waitingForUaePassReturn = true; + + final launched = await launchUrl( + launchUri, + mode: LaunchMode.externalApplication, + ); + + if (!launched) { + _waitingForUaePassReturn = false; + debugPrint('UAE PASS launch failed'); + } + + return NavigationActionPolicy.CANCEL; + } + + return NavigationActionPolicy.ALLOW; + }, + + // ── Step 8: monitor URLs for OAuth completion ──────────────────── + onLoadStart: (c, url) => _checkOAuthComplete(c, url), + onLoadStop: (c, url) => _checkOAuthComplete(c, url), + onUpdateVisitedHistory: (c, url, _) => _checkOAuthComplete(c, url), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/routes/auth_routes/uae_pass_oauth_state.dart b/lib/presentation/routes/auth_routes/uae_pass_oauth_state.dart new file mode 100644 index 00000000..6d4ee6da --- /dev/null +++ b/lib/presentation/routes/auth_routes/uae_pass_oauth_state.dart @@ -0,0 +1,15 @@ +/// Singleton that survives app-to-app transitions. +class UaePassOAuthState { + UaePassOAuthState._(); + static final instance = UaePassOAuthState._(); + + String? providerName; + String? oauthCode; + String? oauthState; + + void clear() { +providerName = null; + oauthCode = null; + oauthState = null; + } +} \ No newline at end of file diff --git a/lib/presentation/routes/auth_routes/uae_pass_resume_auth_screen.dart b/lib/presentation/routes/auth_routes/uae_pass_resume_auth_screen.dart deleted file mode 100644 index 4f63ae09..00000000 --- a/lib/presentation/routes/auth_routes/uae_pass_resume_auth_screen.dart +++ /dev/null @@ -1,264 +0,0 @@ - -import 'package:flutter/material.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:pocketbase/pocketbase.dart'; -import 'package:uae_stat/config/api_config.dart'; -import 'package:uae_stat/config/uae_pass_oauth_state.dart'; -import 'package:uae_stat/domain/use_cases/auth_use_case.dart'; -import 'package:uae_stat/infrastructure/services/pocketbase_service.dart'; -import 'package:uae_stat/presentation/components/dialogs.dart'; -import 'package:uae_stat/presentation/components/indicators/locale_provider.dart'; -import 'package:uae_stat/config/theme/theme_provider.dart'; -import 'package:uae_stat/config/theme/app_theme.dart'; -import 'package:uae_stat/config/theme/service.dart'; -import 'package:uae_stat/l10n/app_localizations.dart'; -import 'package:http/http.dart' as http; - -/// After UAE PASS app redirects back to our app via ae.gov.fcsc.stats://resume_authn?url=..., -/// we load [urlToLoad] in a WebView. When that page redirects to oauth2-redirect?code=..., -/// we extract the code and complete auth with PocketBase, then navigate to home/profile. -class UaePassResumeAuthScreen extends ConsumerStatefulWidget { - const UaePassResumeAuthScreen({super.key, this.urlToLoad}); - - final String? urlToLoad; - - @override - ConsumerState createState() => - _UaePassResumeAuthScreenState(); -} - -class _UaePassResumeAuthScreenState - extends ConsumerState { - final pb = PocketBase(apiUrl); - bool _completed = false; - bool _isFailure = false; - - @override - void initState() { - super.initState(); - final url = widget.urlToLoad; - if (url == null || url.isEmpty) { - _isFailure = true; - _completed = true; - } - } - - Future _completeAuth(String code) async { - if (_completed) return; - final state = UaePassOAuthState.instance; - if (!state.hasPending) { - if (mounted) _showErrorAndGoLogin(); - return; - } - _completed = true; - try { - final uiLocalesTag = state.pendingUiLocales; - final auth = await pb.collection('users').authWithOAuth2Code( - state.providerName!, - code, - state.codeVerifier!, - state.redirectUrl!, - ); - state.clear(); - PocketBaseService.authStore.save(auth.token, auth.record); - final session = await ref - .read(authUseCaseProvider.notifier) - .googleAndAppleAuthencation(auth); - if (!mounted) return; - final userId = session.id; - await _loginCountApi(userId); - await _updateOAuthUserDetails(userId, auth.meta, auth.token); - await _getDeviceToken(userId, auth.token); - await _initializeTheme(userId); - final isProfileComplete = - auth.record?.data['is_profile_completed'] == true; - if (isProfileComplete) { - final locTag = uiLocalesTag; - if (locTag == 'ar') { - ref.read(localeProvider.notifier).setLocale(const Locale('ar')); - } else if (locTag == 'en') { - ref.read(localeProvider.notifier).setLocale(const Locale('en')); - } else { - final preferredLanguage = auth.record?.data['language'] ?? 'en'; - if (preferredLanguage == 'ar') { - ref.read(localeProvider.notifier).setLocale(const Locale('ar')); - } else { - ref.read(localeProvider.notifier).setLocale(const Locale('en')); - } - } - context.go('/myhomepage'); - } else { - final locTag = uiLocalesTag; - if (locTag == 'ar') { - ref.read(localeProvider.notifier).setLocale(const Locale('ar')); - } else if (locTag == 'en') { - ref.read(localeProvider.notifier).setLocale(const Locale('en')); - } - context.go('/profile/$userId'); - } - } catch (e) { - // Log PocketBase / UAE PASS error to help diagnose "Auth failed" causes. - // This will usually contain the upstream token/userinfo error from UAE PASS. - // Example: invalid_grant, invalid_redirect_uri, etc. - // Check your PocketBase logs as well for full error details. - debugPrint('UAEPASS authWithOAuth2Code error: $e'); - state.clear(); - if (mounted) _showErrorAndGoLogin(); - } - } - - void _handleOAuthRedirectError(String error) { - // UAE PASS cancellation/auth errors can show up as OAuth2 redirect - // parameters. We intercept and route back to the SP login screen with - // the correct cancellation message. - final msg = switch (error) { - // Authentication cancel codes per UAE PASS assessment guidelines. - 'invalid_request' || - 'login_required' || - 'access_denied' || - 'cancelledOnApp' => - AppLocalizations.of(context)?.uae_pass_user_cancelled ?? - 'User cancelled the login', - _ => AppLocalizations.of(context)?.uae_pass_login_error ?? - 'Something went wrong during the login, please try again later!', - }; - - if (!mounted) return; - context.simpleDialog( - title: AppLocalizations.of(context)?.login_title ?? 'Login', - content: msg, - ).then((_) => context.go('/login')); - } - - Future _loginCountApi(String userId) async { - try { - final url = Uri.parse('${apiUrl}api/login_success?id=$userId'); - await http.get(url, - headers: {'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A'}); - } catch (_) {} - } - - Future _updateOAuthUserDetails( - String userId, Map metaDetails, String token) async { - try { - final userDetails = metaDetails['rawUser'] as Map?; - if (userDetails == null) return; - await pb.collection('users').update( - userId, - body: { - 'user_mail_verify': true, - 'reviewed': true, - 'role': 'user', - 'uname': userDetails['name'], - 'status': 'Approved', - 'is_oauth_login': 1, - }, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', - }, - ); - } catch (_) {} - } - - Future _getDeviceToken(String userId, String authToken) async { - // Device token update can be done from login flow if needed - } - - Future _initializeTheme(String userId) async { - final themeService = ThemeBaseService(); - final theme = await themeService.getUserTheme(userId, ref); - final platformBrightness = MediaQuery.of(context).platformBrightness; - ref - .read(themeProvider.notifier) - .updateTheme(AppThemeExtension.fromString(theme), platformBrightness); - } - - void _showErrorAndGoLogin() { - context.simpleDialog( - title: AppLocalizations.of(context)?.login_title ?? 'Login', - content: AppLocalizations.of(context)?.uae_pass_login_error ?? - 'Something went wrong during the login, please try again later!', - ).then((_) => context.go('/login')); - } - - @override - Widget build(BuildContext context) { - if (_isFailure || widget.urlToLoad == null || widget.urlToLoad!.isEmpty) { - return Scaffold( - appBar: AppBar(title: const Text('Sign in with UAE PASS')), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Invalid or missing resume URL.'), - const SizedBox(height: 16), - TextButton( - onPressed: () => context.go('/login'), - child: const Text('Back to Login'), - ), - ], - ), - ), - ); - } - - return Scaffold( - appBar: AppBar(title: const Text('Sign in with UAE PASS')), - body: InAppWebView( - initialUrlRequest: - URLRequest(url: WebUri(Uri.parse(widget.urlToLoad!).toString())), - initialSettings: InAppWebViewSettings( - javaScriptEnabled: true, - domStorageEnabled: true, - useShouldOverrideUrlLoading: true, - ), - shouldOverrideUrlLoading: (controller, navigationAction) async { - final uri = navigationAction.request.url; - if (uri == null) return null; - final s = uri.toString(); - if (s.contains('/api/oauth2-redirect') && s.contains('code=')) { - final parsed = Uri.tryParse(s); - final code = parsed?.queryParameters['code']; - if (code != null && code.isNotEmpty) { - _completeAuth(code); - return NavigationActionPolicy.CANCEL; - } - } - if (s.contains('/api/oauth2-redirect') && s.contains('error=')) { - final parsed = Uri.tryParse(s); - final error = parsed?.queryParameters['error']; - if (error != null && error.isNotEmpty) { - // Stop further loading and show the UAE PASS error message. - _handleOAuthRedirectError(error); - return NavigationActionPolicy.CANCEL; - } - } - return null; - }, - onLoadStart: (controller, url) async { - if (url == null) return; - final s = url.toString(); - if (s.contains('/api/oauth2-redirect') && s.contains('code=')) { - final uri = Uri.parse(s); - final code = uri.queryParameters['code']; - if (code != null && code.isNotEmpty) { - await controller.stopLoading(); - await _completeAuth(code); - } - } - if (s.contains('/api/oauth2-redirect') && s.contains('error=')) { - final uri = Uri.parse(s); - final error = uri.queryParameters['error']; - if (error != null && error.isNotEmpty) { - await controller.stopLoading(); - _handleOAuthRedirectError(error); - } - } - }, - ), - ); - } -} diff --git a/lib/presentation/routes/auth_routes/yes_profile_linking_login.dart b/lib/presentation/routes/auth_routes/yes_profile_linking_login.dart new file mode 100644 index 00000000..af7b7773 --- /dev/null +++ b/lib/presentation/routes/auth_routes/yes_profile_linking_login.dart @@ -0,0 +1,2872 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:android_intent_plus/android_intent.dart'; +import 'package:android_intent_plus/flag.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_uae_pass/flutter_uae_pass.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:the_validator/the_validator.dart'; +import 'package:uae_stat/config/connectivity_provider.dart'; +import 'package:uae_stat/config/theme/app_theme.dart'; +import 'package:uae_stat/config/theme/service.dart'; +import 'package:uae_stat/config/theme/theme_provider.dart'; +import 'package:uae_stat/domain/use_cases/auth_use_case.dart'; +import 'package:uae_stat/domain/use_cases/language.dart'; +import 'package:uae_stat/infrastructure/services/img_asset_paths/banner_asset_path.dart'; +import 'package:uae_stat/infrastructure/services/img_asset_paths/icons/misc_icon_asset_path.dart'; +import 'package:uae_stat/infrastructure/services/pocketbase_service.dart'; +import 'package:uae_stat/l10n/app_localizations.dart'; +import 'package:uae_stat/presentation/components/dialogs.dart'; +import 'package:uae_stat/presentation/components/space.dart'; +import 'package:uae_stat/presentation/components/themed_text_field.dart'; +import 'package:http/http.dart' as http; +import 'package:uae_stat/presentation/routes/auth_routes/oauth_webview.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:pocketbase/pocketbase.dart'; +import 'package:uae_stat/config/api_config.dart'; +import 'package:uae_stat/config/uae_pass_locale.dart'; +import 'package:uae_stat/config/uae_pass_oauth_errors.dart'; +import 'package:uae_stat/config/uae_pass_oauth_state.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import '../../components/indicators/locale_provider.dart'; +import '../../components/my_toggle.dart'; + +class LoginRoute extends HookConsumerWidget with WidgetsBindingObserver { + final String? fromPage; + final Map? userInfo; + + LoginRoute({ + Key? key, + this.fromPage, + this.userInfo, + }) : super(key: key); + /// Always use [PocketBaseService.client] so OAuth PKCE + realtime state match the rest of the app. + PocketBase get pb => PocketBaseService.client; + static const deepLinkRedirect = 'ae.gov.fcsc.stats://auth-callback'; + final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); + // final _pb = PocketBase('http://127.0.0.1:8090'); + // final bool isLoginScreen; // Pass `true` if this is the login screen + // LoginRoute({Key? key, required this.isLoginScreen}) : super(key: key); + dynamic userData; + String? role; + int? loginCount; + dynamic preferredLanguage; + final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance; + // final FirebaseAuth _auth = FirebaseAuth.instance; + static final formKey = GlobalKey(); + String? authCode; + String? accessToken; + ProfileData? profileData; + // final _uaePass = UaePass(); + bool get cameFromLinking => fromPage == 'fcscprofilelinking'; + + final UaePass _uaePass = UaePass(); + bool _isInit = false; + + Future profileStatus(String userId, WidgetRef ref) async { + try { + // Authenticate admin + // final adminAuth = await pb.admins.authWithPassword( + // 'pb@venbainfotech.com', + // 'pb@venbainfotech.com', + // ); + // final auth = ref.watch(authProvider); + + // final adminToken = await pb.admins.authWithPassword( + // auth.email, + // auth.password, + // ); + + final themeService = ThemeBaseService(); + // final adminToken = await themeService.getAdminToken( + // email: auth.email, + // password: auth.password, + // ); + // Fetch user details + final userDetailsResponse = await pb.collection('users').getOne( + userId, + headers: { + 'Authorization': 'Bearer ${PocketBaseService.authStore.token}', + }, + ); + + // Check if user is verified + role = userDetailsResponse.data['role']; + final bool? isVerified = userDetailsResponse.data['verified']; + final bool? isUserMailVerified = + userDetailsResponse.data['user_mail_verify']; + preferredLanguage = userDetailsResponse.data['language'] ?? ''; + + // Check admin and email verification statuses + if (isVerified == false) { + throw Exception('Admin not approved'); + } + if (isUserMailVerified == false) { + throw Exception('Email not verified'); + } + + // **Set Default Locale in Provider** + // if (preferredLanguage == 'ar') { + // ref.read(localeProvider.notifier).setLocale(const Locale('ar')); + // } else { + // ref.read(localeProvider.notifier).setLocale(const Locale('en')); + // } + + userData = userDetailsResponse; + // Check if 'is_profile_completed' is true + final bool isProfileCompleted = + userDetailsResponse.data['is_profile_completed'] ?? false; + + return isProfileCompleted; + } catch (e) { + rethrow; + } + } + + Future saveUserId(String userId, count) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('userId', userId); // Save userId locally + await prefs.setString('login_count', count.toString()); + } + + /// YES path on FCSC linking: copy email/password user's PocketBase fields onto the + /// UAE PASS OAuth user (`pbUserId`), then delete the normal account and continue as OAuth user. + Future _mergeUaepassIntoNormalUser({ + required String normalUserId, + required WidgetRef ref, + required BuildContext context, + }) async { + if (!cameFromLinking) return; + + final uuid = + (userInfo?['uuid'] ?? userInfo?['sub'])?.toString().trim(); + final uaepassRecordId = userInfo?['pbUserId'] as String?; + final uaepassToken = userInfo?['pbToken'] as String?; + + if (uuid == null || uuid.isEmpty) return; + if (uaepassRecordId == null || + uaepassRecordId.isEmpty || + uaepassToken == null || + uaepassToken.isEmpty) { + debugPrint( + 'UAE PASS merge: missing pbUserId/pbToken — cannot merge into OAuth user.', + ); + return; + } + if (uaepassRecordId == normalUserId) { + debugPrint('UAE PASS merge: OAuth record equals normal user — skipping.'); + return; + } + + final normalToken = PocketBaseService.authStore.token; + if (normalToken.isEmpty) return; + + try { + final normalRecord = await pb.collection('users').getOne(normalUserId); + + final mergeBody = Map.from(normalRecord.data); + const skipKeys = { + 'password', + 'passwordConfirm', + 'oldPassword', + 'id', + 'collectionId', + 'collectionName', + 'created', + 'updated', + 'expand', + }; + mergeBody.removeWhere((k, _) => skipKeys.contains(k)); + mergeBody['uuid'] = uuid; + mergeBody['is_oauth_login'] = 1; + + // PocketBase enforces unique email/username. While the normal user still exists, + // setting these on the OAuth record causes a 400 duplicate error. + final desiredEmail = mergeBody['email']?.toString().trim(); + final desiredUsername = mergeBody['username']?.toString().trim(); + if ((desiredEmail != null && desiredEmail.isNotEmpty) || + (desiredUsername != null && desiredUsername.isNotEmpty)) { + final ts = DateTime.now().millisecondsSinceEpoch; + final normalUserTempBody = {}; + if (desiredEmail != null && desiredEmail.isNotEmpty) { + normalUserTempBody['email'] = 'merged_${normalUserId}_$ts@merge.local'; + } + if (desiredUsername != null && desiredUsername.isNotEmpty) { + normalUserTempBody['username'] = 'merged_${normalUserId}_$ts'; + } + if (normalUserTempBody.isNotEmpty) { + await pb.collection('users').update( + normalUserId, + body: normalUserTempBody, + headers: { + 'Authorization': 'Bearer $normalToken', + }, + ); + } + } + + final oauthUpdated = await pb.collection('users').update( + uaepassRecordId, + body: mergeBody, + headers: { + 'Authorization': 'Bearer $uaepassToken', + }, + ); + + await pb.collection('users').delete( + normalUserId, + headers: { + 'Authorization': 'Bearer $normalToken', + }, + ); + + pb.authStore.save(uaepassToken, oauthUpdated); + PocketBaseService.authStore.save(uaepassToken, oauthUpdated); + + final mergedAuth = RecordAuth( + token: uaepassToken, + record: oauthUpdated, + meta: {}, + ); + try { + await ref + .read(authUseCaseProvider.notifier) + .googleAndAppleAuthencation(mergedAuth); + } catch (e) { + // Linking path can yield an OAuth row with verified=false even though + // UAE PASS auth succeeded. Keep the active PB auth session and proceed. + if (e != LoginError.emailAddressNotVerified) { + rethrow; + } + debugPrint( + 'UAE PASS merge: continuing with OAuth session despite unverified email.', + ); + } + + final oauthUserId = oauthUpdated.id; + + final url = Uri.parse('${apiUrl}api/login_success?id=$oauthUserId'); + try { + final response = await http.get( + url, + headers: {'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A'}, + ); + if (response.statusCode == 200) { + final count = jsonDecode(response.body); + loginCount = count['login_count']; + } + } catch (e) { + debugPrint('UAE PASS merge: login_success API error: $e'); + } + + await saveUserId(oauthUserId, loginCount); + await getDeviceToken( + context, + oauthUserId, + ref, + uaepassToken, + ); + await initializeTheme(context, ref, oauthUserId); + + if (!context.mounted) return; + + final bool isProfileComplete = + await profileStatus(oauthUserId, ref); + + final String? intendedPath = + GoRouterState.of(context).uri.queryParameters['intendedPath']; + + if (UaePassOAuthState.instance.pendingUiLocales == 'ar' || + UaePassOAuthState.instance.pendingUiLocales == 'en') { + applyUaePassDashboardLocale(ref, context); + } + + if (!context.mounted) return; + + if (isProfileComplete) { + context.go(intendedPath ?? '/myhomepage'); + } else { + context.go('/profile/$oauthUserId'); + } + } catch (e, st) { + debugPrint('UAE PASS merge (normal → OAuth user): $e'); + debugPrint('$st'); + } + } + + Future _showUaePassStyledDialog({ + required BuildContext context, + required String message, + required String actionLabel, + bool showErrorTitle = false, + }) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + final isDark = Theme.of(dialogContext).brightness == Brightness.dark; + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + backgroundColor: isDark ? const Color(0xFF1F1F1F) : Colors.white, + title: showErrorTitle + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircleAvatar( + radius: 18, + backgroundColor: Color(0xFFFFE9E9), + child: Icon( + Icons.priority_high_rounded, + color: Color(0xFFD83731), + size: 22, + ), + ), + const SizedBox(height: 8), + Text( + context.translate('Error!', 'خطأ!'), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: context.translate('Roboto', 'NotoKufi'), + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : const Color(0xFF1A1A1A), + ), + ), + ], + ) + : null, + content: Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: context.translate('Roboto', 'NotoKufi'), + color: isDark ? Colors.white : const Color(0xFF3C3C3C), + fontSize: 14, + ), + ), + actions: [ + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => + Navigator.of(dialogContext, rootNavigator: true).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFB68A34), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + actionLabel, + style: TextStyle( + fontFamily: context.translate('Roboto', 'NotoKufi'), + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ); + }, + ); + } + + // Future getDeviceToken() async { + // String? token = await FirebaseMessaging.instance.getToken(); + // print("FCM Token: $token"); + // } + + Future getDeviceToken( + BuildContext context, String userId, WidgetRef ref, authtoken) async { + await updateDeviceToken(userId, 'fcmToken', ref, authtoken); + try { + // await _firebaseMessaging.requestPermission(); + // print("FCM Token Entry"); + // String? token = await FirebaseMessaging.instance.getToken(); + // print("FCM Token: $token"); + + if (Platform.isIOS || Platform.isMacOS) { + // Get APNS token + String? token = await _firebaseMessaging.getAPNSToken(); + + // Retry if null + if (token == null) { + await Future.delayed(const Duration(seconds: 3)); + token = await _firebaseMessaging.getAPNSToken(); + } + + if (token != null) { + await updateDeviceToken(userId, token, ref, authtoken); + } + } else if (Platform.isAndroid) { + // Get FCM token + String? fcmToken = await _firebaseMessaging.getToken(); + + if (fcmToken != null) { + await updateDeviceToken(userId, fcmToken, ref, authtoken); + } + } + + // if (token != null) { + // await updateDeviceToken(userId, token, ref,authtoken); + // + // // Automatically copy to clipboard + // // Clipboard.setData(ClipboardData(text: token)); + // + // // Show token in Alert Dialog + // // if (context.mounted) { + // // showDialog( + // // context: context, + // // builder: (context) { + // // return AlertDialog( + // // title: Text("FCM Token"), + // // content: Column( + // // mainAxisSize: MainAxisSize.min, + // // children: [ + // // SelectableText(token), // Allows manual selection + // // SizedBox(height: 10), + // // Text( + // // "Token copied to clipboard ✅", + // // style: TextStyle(color: Colors.green), + // // ), + // // ], + // // ), + // // actions: [ + // // TextButton( + // // onPressed: () => Navigator.pop(context), + // // child: Text("OK"), + // // ), + // // ], + // // ); + // // }, + // // ); + // // } + // } + } catch (e) {} + } + + Future updateDeviceToken( + String userId, String token, WidgetRef ref, authtoken) async { + print('UUID'); + final url = "${pb.baseUrl}api/collections/users/records/$userId"; + + try { + // Authenticate admin (Persist session globally instead) + // await pb.admins.authWithPassword( + // 'pb@venbainfotech.com', + // 'pb@venbainfotech.com', + // ); + + // final auth = ref.watch(authProvider); + // + // await pb.admins.authWithPassword( + // auth.email, + // auth.password, + // ); + + final themeService = ThemeBaseService(); + // final adminToken = await themeService.getAdminToken( + // email: auth.email, + // password: auth.password, + // ); + + final response = await http.patch( + Uri.parse(url), + headers: { + 'Content-Type': 'application/json', // Add this + 'Authorization': + 'Bearer $authtoken', // Correct way to send auth token + }, + body: jsonEncode({ + "device_token": token, + if (cameFromLinking) "uuid": userInfo?['uuid'], + }), + ); + + if (response.statusCode == 200) { + print("✅ Device token updated successfully!"); + } else { + print("❌ Failed to update device token: ${response.body}"); + } + } catch (e) { + print("🚨 Error updating device token: $e"); + } + } + + // Function to send the reset password request + Future forgotPasswordMail(String email) async { + final url = + Uri.parse("${pb.baseUrl}api/auth/request-password-reset?email=$email"); + + try { + final response = await http + .get(url, headers: {'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A'}); + final body = json.decode(response.body); + if (response.statusCode == 200) { + return body['status'] ?? 'Reset link sent successfully.'; + } else { + final errorResponse = json.decode(response.body); + throw Exception( + errorResponse['status'] ?? "Failed to send reset email."); + } + } catch (error) { + throw Exception("Something went wrong. Please try again."); + } + } + + // Future authenticateAndStoreData() async { + // try { + // // Perform authentication + // final authData = await pb + // .collection("users") + // .authWithPassword('pb@venbainfotech.com', 'pb@venbainfotech.com'); + // + // // Check if authentication is successful + // if (pb.authStore.isValid) { + // print("Authentication successful!"); + // print("Token: ${pb.authStore.token}"); + // // print("User ID: ${pb.authStore.record.id}"); + // + // // Store the token and user ID in local storage + // await _secureStorage.write( + // key: "FlutterSecureStorage.session", value: pb.authStore.token); + // // await _secureStorage.write( + // // key: "FlutterSecureStorage.userId", value: pb.authStore.record.id); + // + // print("Session and User ID stored in local storage."); + // } else { + // print("Authentication failed."); + // } + // } catch (e) { + // print("Error during authentication: $e"); + // } + // } + + emailValidation({String? message, String? requiredMessage}) { + return (fieldValue) { + if (fieldValue == null || fieldValue.trim().isEmpty) { + return requiredMessage ?? "Email is required"; + } + if (!Validator.isEmail(fieldValue)) { + if (fieldValue.contains(" ")) { + return "Space is present, email is not correct"; + } + return message ?? "Email is not correct"; + } + return null; // Validation passed + }; + } + + void _showAppLinkDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Enable App Links'), + content: const Text( + 'To enable links like www.fcsc.com to open in this app:\n\n' + '1. Tap "Open Settings".\n' + '2. Then tap on "Supported web addresses".\n' + '3. Make sure your domain is enabled.\n\n' + 'This helps open web links directly in the app.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Later'), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + openAppLinkSettings(); + + // Optionally mark as shown so dialog doesn't show again + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('supported_links_enabled', true); + }, + child: const Text('Open Settings'), + ), + ], + ), + ); + } + + void openAppLinkSettings() { + const packageName = 'ae.gov.fcsc.stats'; // Replace with your app's package + final intent = AndroidIntent( + action: 'android.settings.APP_OPEN_BY_DEFAULT_SETTINGS', + data: 'package:$packageName', + flags: [Flag.FLAG_ACTIVITY_NEW_TASK], + ); + intent.launch(); + } + + Future checkAndShowDialogOnce(BuildContext context) async { + final prefs = await SharedPreferences.getInstance(); + final shown = prefs.getBool('supported_links_enabled') ?? false; + + if (!shown) { + _showAppLinkDialog(context); + } + } + + + Future initializeTheme( + BuildContext context, WidgetRef ref, String userId) async { + final themeService = ThemeBaseService(); + final theme = await themeService.getUserTheme(userId, ref); // e.g., "dark" + final platformBrightness = MediaQuery.of(context).platformBrightness; + ref + .read(themeProvider.notifier) + .updateTheme(AppThemeExtension.fromString(theme), platformBrightness); + final themeMode = ref.watch(themeProvider); + print('themeMode Login'); + print('themeMode $themeMode'); + } + + /// Returns true if a UAE PASS native app is installed (production or staging). + /// Live uses `uaepass://`; staging uses `uaepassstg://`. Probing only staging + /// makes [useUaePassDeepLink] false on prod-only devices and the WebView then + /// tries to load `uaepass://` inline → net::ERR_UNKNOWN_URL_SCHEME. + Future _isUaePassInstalled() async { + // iOS: Info.plist lists uaepass / uaepassstg / uaepassqa in LSApplicationQueriesSchemes. + final prod = await canLaunchUrl(Uri.parse('uaepass://')); + final stg = await canLaunchUrl(Uri.parse('uaepassstg://')); + final qa = await canLaunchUrl(Uri.parse('uaepassqa://')); + return prod || stg || qa; + } + + /// PocketBase `auth-with-oauth2` errors include JSON in [ClientException.response]. + String _formatPocketBaseAuthFailure(Object error) { + if (error is ClientException) { + final parts = [ + 'statusCode=${error.statusCode}', + if (error.url != null) 'url=$error.url', + if (error.response.isNotEmpty) 'response=${error.response}', + if (error.originalError != null) 'originalError=${error.originalError}', + ]; + return 'ClientException(${parts.join(', ')})'; + } + return error.toString(); + } + + /// UAE PASS documented cancellation codes → EN/AR copy (reference table). + String? _translateUaePassCancellationMessage( + BuildContext context, + UaePassOAuthFlowKind flowKind, + String code, + ) { + if (flowKind == UaePassOAuthFlowKind.authentication && + uaePassIsUserCancelledLogin(code)) { + return context.translate( + 'User cancelled the login', + 'قام المستخدم بإلغاء تسجيل الدخول', + ); + } + if (flowKind == UaePassOAuthFlowKind.digitalSignature && + uaePassIsUserCancelledSigning(code)) { + return context.translate( + 'User cancelled the signing process', + 'قام المستخدم بإلغاء عملية التوقيع', + ); + } + return null; + } + + /// Mobile: OAuth code is delivered to [`auth-with-oauth2`] by exchanging the + /// redirect URL `code` with PKCE — **not** only via SSE `@oauth2` (SSE often + /// fails after switching to the UAE PASS app). + Future _signInWithUaePassMobileApp( + BuildContext context, + WidgetRef ref, { + required bool uaePassInstalled, + required String acrValues, + }) async { + final pb = PocketBaseService.client; + final methods = await pb.collection('users').listAuthMethods(); + final provider = + methods.authProviders.firstWhere((p) => p.name == 'oidc'); + final redirectUri = pb.buildUrl('/api/oauth2-redirect'); + + final unsubConnect = + await pb.realtime.subscribe('PB_CONNECT', (_) {}); + try { + final clientId = pb.realtime.clientId; + if (clientId.isEmpty) { + throw StateError( + 'PocketBase realtime clientId is empty — SSE did not connect.', + ); + } + + final authUrl = Uri.parse(provider.authUrl + redirectUri.toString()); + final qp = Map.from(authUrl.queryParameters); + qp['state'] = clientId; + qp['scope'] = 'urn:uae:digitalid:profile:general'; + qp['acr_values'] = acrValues; + qp['ui_locales'] = uaePassOidcUiLocales(context, ref); + + final mobileUri = authUrl.replace(queryParameters: qp); + + final authCompleter = Completer(); + + Future handleOAuthError( + String errorCode, + String? errorDescription, + ) async { + if (authCompleter.isCompleted) return; + authCompleter.completeError( + UaePassOAuthRedirectException( + errorCode, + errorDescription: errorDescription, + flowKind: UaePassOAuthFlowKind.authentication, + ), + ); + } + + Future handleRedirect(String code, String? oauthState) async { + if (authCompleter.isCompleted) { + return; + } + try { + final auth = await pb.collection('users').authWithOAuth2Code( + 'oidc', + code, + provider.codeVerifier, + redirectUri.toString(), + ); + if (!authCompleter.isCompleted) { + authCompleter.complete(auth); + } + } catch (e, st) { + debugPrint( + 'UAE PASS auth-with-oauth2 FAILED: ${_formatPocketBaseAuthFailure(e)}', + ); + if (e is ClientException && e.response.isNotEmpty) { + try { + debugPrint( + 'UAE PASS PocketBase failure JSON: ${jsonEncode(e.response)}', + ); + } catch (_) { + debugPrint('UAE PASS PocketBase failure response: ${e.response}'); + } + } + if (!authCompleter.isCompleted) { + authCompleter.completeError(e, st); + } + } + } + + if (!context.mounted) { + throw StateError('Context unmounted before UAE PASS WebView'); + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OAuthWebView( + authUrl: mobileUri.toString(), + useUaePassDeepLink: uaePassInstalled, + onPocketBaseRedirect: handleRedirect, + onPocketBaseOAuthError: handleOAuthError, + ), + ), + ); + + return await authCompleter.future; + } finally { + await unsubConnect(); + } + } + +// ── Main auth entry point ────────────────────────────────────────────────── + + Future signInWithUaePass( + BuildContext context, + WidgetRef ref, + ValueNotifier isFlowActive, + ) async { + isFlowActive.value = true; + var sdkOAuthCompleted = false; + try { + // Check app presence ONCE before starting OAuth. + final uaePassInstalled = await _isUaePassInstalled(); + + // acr_values per UAE PASS docs: + // app installed -> mobileondevice (M2M flow) + // app missing -> level:low (web-form / push-to-other-device) + final acrValues = uaePassInstalled + ? 'urn:digitalid:authentication:flow:mobileondevice' + // : 'urn:digitalid:authentication:flow:mobileondevice'; + : 'urn:safelayer:tws:policies:authentication:level:low'; + + UaePassOAuthState.instance.clear(); + + debugPrint( + 'UAE PASS sign-in started. installed=$uaePassInstalled, acr_values=$acrValues', + ); + + // Web: SDK completes OAuth via SSE `@oauth2` (same browser session). + // Mobile: UAE PASS app-switch breaks SSE state alignment — exchange redirect + // `code` via [OAuthWebView.onPocketBaseRedirect] + [authWithOAuth2Code]. + final RecordAuth recordAuth; + if (kIsWeb) { + recordAuth = await pb.collection('users').authWithOAuth2( + 'oidc', + (url) async { + debugPrint('UAE PASS URL $url'); + final uiLocales = uaePassOidcUiLocales(context, ref); + UaePassOAuthState.instance.pendingUiLocales = uiLocales; + final mobileUri = url.replace( + queryParameters: { + ...url.queryParameters, + 'scope': 'urn:uae:digitalid:profile:general', + 'acr_values': acrValues, + 'ui_locales': uiLocales, + }, + ); + debugPrint('UAE PASS mobileUri $mobileUri'); + if (!await launchUrl( + mobileUri, + mode: LaunchMode.externalApplication, + )) { + throw Exception('Could not open UAE PASS URL in browser'); + } + }, + ).timeout( + const Duration(seconds: 120), + onTimeout: () { + throw TimeoutException( + 'UAE PASS login timed out — check PocketBase realtime (SSE) to ${pb.baseUrl}', + ); + }, + ); + } else { + recordAuth = await _signInWithUaePassMobileApp( + context, + ref, + uaePassInstalled: uaePassInstalled, + acrValues: acrValues, + ).timeout( + const Duration(seconds: 120), + onTimeout: () { + throw TimeoutException( + 'UAE PASS login timed out — complete login in WebView or try again.', + ); + }, + ); + } + + if (recordAuth.token.isEmpty || recordAuth.record == null) { + throw StateError('UAE PASS login returned empty auth'); + } + + sdkOAuthCompleted = true; + pb.authStore.save(recordAuth.token, recordAuth.record!); + PocketBaseService.authStore.save(recordAuth.token, recordAuth.record!); + + debugPrint( + 'UAE PASS PocketBase OAuth finished via SDK (auth-with-oauth2): $sdkOAuthCompleted', + ); + + final meta = recordAuth.meta as Map?; + final rawUser = meta?['rawUser'] as Map?; + final uuid = rawUser?['uuid'] ?? rawUser?['sub']; + final mail = rawUser?['email'] ?? rawUser?['sub']; + debugPrint('UAE PASS Mail: $mail'); + debugPrint('UAE PASS UUID: $uuid'); + debugPrint('UAE PASS authData: ${recordAuth.toJson()}'); + + return recordAuth; + } catch (e, st) { + debugPrint( + 'UAE PASS PocketBase OAuth finished via SDK (auth-with-oauth2): $sdkOAuthCompleted', + ); + debugPrint( + 'UAE PASS sign-in failed: ${_formatPocketBaseAuthFailure(e)}', + ); + if (e is ClientException && e.response.isNotEmpty) { + try { + debugPrint( + 'UAE PASS PocketBase failure JSON: ${jsonEncode(e.response)}', + ); + } catch (_) { + debugPrint('UAE PASS PocketBase failure response: ${e.response}'); + } + } + debugPrint('$st'); + if (context.mounted) { + if (e is TimeoutException) { + await _showUaePassStyledDialog( + context: context, + message: context.translate( + 'UAE PASS is taking too long to respond. Please try again.', + 'استغرق UAE PASS وقتًا أطول من المتوقع. يرجى المحاولة مرة أخرى.', + ), + actionLabel: context.translate('OK', 'موافق'), + showErrorTitle: true, + ); + } else { + String? cancellationMsg; + if (e is UaePassOAuthRedirectException) { + cancellationMsg = _translateUaePassCancellationMessage( + context, + e.flowKind, + e.errorCode, + ); + } else if (e is ClientException && e.response.isNotEmpty) { + final code = + uaePassTryExtractOAuthErrorCodeFromResponse(e.response); + if (code != null) { + cancellationMsg = _translateUaePassCancellationMessage( + context, + UaePassOAuthFlowKind.authentication, + code, + ); + } + } + if (cancellationMsg != null) { + await _showUaePassStyledDialog( + context: context, + message: cancellationMsg, + actionLabel: context.translate('OK', 'موافق'), + showErrorTitle: false, + ); + } + // else { + // await _showUaePassStyledDialog( + // context: context, + // message: context.translate( + // 'UAE PASS sign-in failed. Please try again.', + // 'فشل تسجيل الدخول عبر UAE PASS. يرجى المحاولة مرة أخرى.', + // ), + // actionLabel: context.translate('OK', 'موافق'), + // showErrorTitle: true, + // ); + // } + } + } + return null; + } finally { + isFlowActive.value = false; + } + } + + /// After [signInWithUaePass] returns a [RecordAuth], run the same UAE PASS UUID / + /// profile-linking checks and session + routing as [oAuthGoogleAndAppleLogin] for `oidc`. + Future _continueAfterUaePassRecordAuth( + BuildContext context, + WidgetRef ref, + RecordAuth authData, { + bool linkToExistingAccount = false, + }) async { + var mergeOpenIdSuccess = false; + Map? mergeOpenIdData; + if (!linkToExistingAccount) { + try { + final meta = authData.meta as Map?; + final rawUser = meta?['rawUser'] as Map?; + final uuid = rawUser?['uuid'] ?? rawUser?['sub']; + final uuidStr = uuid?.toString().trim() ?? ''; + final emailID = (rawUser?['email'] ?? '').toString().trim(); + final id = authData.record?.id ?? ''; + if (id.isNotEmpty && emailID.isNotEmpty && uuidStr.isNotEmpty) { + final mergeResponse = await _callMergeOpenIDConnectUserApi( + id: id, + uuid: uuidStr, + email: emailID, + ); + mergeOpenIdSuccess = mergeResponse['success'] == true; + final rawData = mergeResponse['data']; + if (rawData is Map) { + mergeOpenIdData = rawData; + } + } + // If MergeOpenIDConnectUser succeeds, skip the profile-linking branch + // and continue with the regular post-auth flow below. + if (!mergeOpenIdSuccess && uuid is String && uuid.isNotEmpty) { + final existing = await pb.collection('users').getList( + page: 1, + perPage: 1, + filter: 'uuid = "$uuid"', + ); + if (existing.items.isEmpty) { + try { + await ref + .read(authUseCaseProvider.notifier) + .googleAndAppleAuthencation(authData); + } catch (e) { + debugPrint( + 'UAEPASS session store error before linking screen: $e', + ); + } + + if (context.mounted) { + final Map linkingUser = + Map.from(rawUser ?? const {}); + linkingUser['pbUserId'] = authData.record?.id; + linkingUser['pbToken'] = authData.token; + + GoRouter.of(context).go( + '/fcscprofilelinking', + extra: linkingUser, + ); + } + return; + } + } + } catch (e) { + debugPrint('UAEPASS linking check error: $e'); + } + } + + if (authData.token.isEmpty || authData.record == null) { + debugPrint('UAE PASS: invalid authData after sign-in'); + return; + } + + final userDetails = authData.record!; + final userId = userDetails.id; + var isProfileComplete = userDetails.data['is_profile_completed'] ?? ''; + if (mergeOpenIdSuccess && + mergeOpenIdData != null && + mergeOpenIdData.containsKey('is_profile_completed')) { + isProfileComplete = mergeOpenIdData['is_profile_completed']; + } + + await context.loaderWithErrorDialog( + () => ref + .read(authUseCaseProvider.notifier) + .googleAndAppleAuthencation(authData), + errorDialogBuilder: (error, [StackTrace? stackTrace]) { + if (error == LoginError.invalidEmailPw) { + return context.simpleDialog( + title: context.translate( + 'Incorrect Credentials', + 'أوراق غير صحيحة', + ), + content: context.translate( + 'Your email or password is invalid. Please try again.', + 'بريدك الإلكتروني أو كلمة المرور غير صالحة. حاول مرة اخرى.', + ), + ); + } + return context.simpleDialog(); + }, + ); + + await loginCountApi(userId); + await initializeTheme(context, ref, userId); + + final String? intendedPath = + GoRouterState.of(context).uri.queryParameters['intendedPath']; + + if (linkToExistingAccount && context.mounted) { + applyUaePassDashboardLocale(ref, context); + context.go(intendedPath ?? '/myhomepage'); + return; + } + + if (isProfileComplete) { + applyUaePassDashboardLocale(ref, context); + if (context.mounted) { + context.go(intendedPath ?? '/myhomepage'); + } + } else { + applyUaePassDashboardLocale(ref, context); + if (context.mounted) { + context.go('/profile/$userId'); + } + } + } + + Future> _callMergeOpenIDConnectUserApi({ + required String id, + required String uuid, + required String email, + }) async { + try { + final url = Uri.parse('${apiUrl}api/MergeOpenIDConnectUser'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A', + }, + body: jsonEncode({ + 'id': id, + 'uuid': uuid, + 'email': email, + }), + ); + debugPrint( + 'MergeOpenIDConnectUser status=${response.statusCode} body=${response.body}', + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + return {'success': false, 'data': null}; + } + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final status = (decoded['status'] ?? '').toString().toLowerCase(); + final data = decoded['data']; + return { + 'success': status == 'success', + 'data': data is Map ? data : null, + }; + } + return {'success': false, 'data': null}; + } catch (e) { + debugPrint('MergeOpenIDConnectUser API failed: $e'); + return {'success': false, 'data': null}; + } + } + + String _uaePassUiLocales(BuildContext context, WidgetRef ref) { + // Return 'ar' or 'en' based on current app locale. + final locale = Localizations.localeOf(context).languageCode; + return locale == 'ar' ? 'ar' : 'en'; + } + + Future oAuthGoogleAndAppleLogin( + BuildContext context, + WidgetRef ref, + platform, { + bool linkToExistingAccount = false, + }) async { + print('platform $platform'); + try { + + final authData; + if (platform == 'oidc') { + // UAE PASS via PocketBase authWithOAuth2. + // On mobile: use in-app WebView. On web: open in external browser + // because UAE PASS sets X-Frame-Options=DENY and cannot load in an iframe. + //https://stg-id.uaepass.ae/idshub/authorize? + // response_type=code& + // client_id=fcsc_uae_stat_mob_stage& + // scope=urn:uae:digitalid:profile:general& + // state=HnlHOJTkTb66Y5H& + // redirect_uri=https://pb.venbait.in/api/oauth2-redirect& + // acr_values=urn:digitalid:authentication:flow:mobileondevice + + authData = await pb.collection('users').authWithOAuth2( + 'oidc', + (url) async { + print('UAE PASS URL $url'); + final uiLocales = uaePassOidcUiLocales(context, ref); + UaePassOAuthState.instance.pendingUiLocales = uiLocales; + final mobileUri = url.replace( + queryParameters: { + ...url.queryParameters, + // 'response_type':'code', + // 'client_id':'fcsc_uae_stat_mob_stage', + 'scope':'urn:uae:digitalid:profile:general', + // 'redirect_uri':'https://pb.venbait.in/api/oauth2-redirect', + 'acr_values':'urn:digitalid:authentication:flow:mobileondevice', + // 'acr_values': 'urn:safelayer:tws:policies:authentication:level:low', + 'ui_locales': uiLocales, + }, + ); + print(mobileUri); + + if (kIsWeb) { + // For Flutter Web, let the browser handle the redirect. + if (!await launchUrl( + mobileUri, + mode: LaunchMode.externalApplication, + )) { + throw Exception('Could not open UAE PASS URL in browser'); + } + } else { + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OAuthWebView( + authUrl: mobileUri.toString(), + useUaePassDeepLink: true, + ), + ), + ); + } + }, + ); + } else { + // Google must use external browser. Apple uses OAuthWebView on mobile per app flow. + authData = await pb.collection('users').authWithOAuth2( + platform, + (url) async { + if (platform == 'apple' && !kIsWeb) { + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OAuthWebView( + authUrl: url.toString(), + useUaePassDeepLink: false, + ), + ), + ); + } else { + // Google must keep the provider URL/state untouched for PB realtime OAuth flow. + final uri = Uri.parse(url.toString()); + final launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + if (!launched) { + throw Exception('Could not open OAuth URL'); + } + } + }, + ); + } + + print('authDataOiDC $authData'); + + // For UAE PASS (oidc), decide whether to go straight to home or to the + // manual profile-linking flow based on the UAE PASS UUID. + if (platform == 'oidc' && !linkToExistingAccount) { + try { + final meta = authData.meta as Map?; + final rawUser = meta?['rawUser'] as Map?; + final uuid = rawUser?['uuid'] ?? rawUser?['sub']; + if (uuid is String && uuid.isNotEmpty) { + // Check if any existing PocketBase user is already linked to this UAE PASS UUID. + final existing = await pb.collection('users').getList( + page: 1, + perPage: 1, + filter: 'uuid = "$uuid"', + ); + if (existing.items.isEmpty) { + // First, persist a valid session so protected routes (like /profile) + // won't redirect back to /login due to "Session expired". + try { + await ref + .read( + authUseCaseProvider.notifier, + ) + .googleAndAppleAuthencation(authData); + } catch (e) { + debugPrint( + 'UAEPASS session store error before linking screen: $e'); + } + + // First-time UAE PASS login: show manual linking screen. + if (context.mounted) { + print('existing.items.isEmpty'); + // Clone rawUser and attach the PocketBase record id so the + // linking/registration screens can update the same record. + final Map linkingUser = + Map.from(rawUser ?? const {}); + linkingUser['pbUserId'] = authData.record?.id; + linkingUser['pbToken'] = authData.token; + + GoRouter.of(context).go( + '/fcscprofilelinking', + extra: linkingUser, + ); + } + return; + } + } + } catch (e) { + // If anything goes wrong in the linking check, fall back to normal login. + debugPrint('UAEPASS linking check error: $e'); + } + } + // ✅ Check if authData is valid + if (authData.token.isNotEmpty && authData.record != null) { + print("✅ Login Successful"); + print(authData.record); + + final metaDetails = authData.meta; + final userDetails = authData.record; + final token = authData.token; + final userId = userDetails?.id; + final email = userDetails?.data['email'] ?? ''; + final isProfileComplete = + userDetails?.data['is_profile_completed'] ?? ''; + final loginCount = userDetails?.data['login_count'] ?? ''; + + print("User ID: ${userId}"); + print("Email: ${email}"); + print("Email: ${isProfileComplete}"); + print("Email: ${loginCount}"); + + final session = await context.loaderWithErrorDialog( + () => ref + .read( + authUseCaseProvider.notifier, + ) + .googleAndAppleAuthencation(authData), + errorDialogBuilder: ( + error, [ + StackTrace? stackTrace, + ]) { + if (error == LoginError.invalidEmailPw) { + return context.simpleDialog( + title: context.translate( + 'Incorrect Credentials', + 'أوراق غير صحيحة', + ), + content: context.translate( + 'Your email or password is invalid. Please try again.', + 'بريدك الإلكتروني أو كلمة المرور غير صالحة. حاول مرة اخرى.', + ), + ); + } + return context.simpleDialog(); + }, + ); + + print("sessionTST - $session"); + + // Post-login side effects should not run for UAE PASS flow. + if (platform != 'oidc') { + + await updateOAuthUserDetails(userId!, metaDetails, ref, token); + // await saveUserId(userId!, loginCount); + await getDeviceToken(context, userId, ref, token); + + } + await loginCountApi(userId!); + await initializeTheme(context, ref, userId); + + // final se = _sessionEntityFromPocketbaseRecord(authData); + // await _LocalAuthRepo.storeLocally(se); + + // Extract intendedPath from query parameters (if coming from redirect) + final String? intendedPath = + GoRouterState.of(context).uri.queryParameters['intendedPath']; + print("Extracted intendedPath after login: $intendedPath"); + + // If we are linking the UAE PASS provider to an already logged-in account, + // don't redirect to profile-linking flow and just continue to home. + if (linkToExistingAccount && context.mounted) { + if (platform == 'oidc') { + applyUaePassDashboardLocale(ref, context); + } + context.go(intendedPath ?? '/myhomepage'); + return; + } + + if (isProfileComplete) { + print('Redirecting to Home / Intended Path'); + if (platform == 'oidc') { + applyUaePassDashboardLocale(ref, context); + } else { + // **Set Default Locale in Provider** + if (preferredLanguage == 'ar') { + ref.read(localeProvider.notifier).setLocale(const Locale('ar')); + } else { + ref.read(localeProvider.notifier).setLocale(const Locale('en')); + } + } + + context.go(intendedPath ?? '/myhomepage'); + } else { + print('Redirecting to Profile Setup'); + if (platform == 'oidc') { + applyUaePassDashboardLocale(ref, context); + } + + context.go('/profile/$userId'); + } + } else { + print("❌ Login failed: Invalid authData"); + // UAE PASS user journey §9: User cancel scenario + if (platform == 'oidc' && context.mounted) { + await _showUaePassStyledDialog( + context: context, + message: context.translate( + 'User cancelled the login', + 'قام المستخدم بإلغاء تسجيل الدخول', + ), + actionLabel: context.translate('Close', 'إغلاق'), + ); + } + } + } catch (e) { + print("❌ Error during OAuth login ($platform): $e"); + if (!context.mounted) return; + // Google fallback: + // In some devices, browser/app switching can finish PB auth but still throw in this flow. + // If authStore is valid, recover and continue instead of showing a false error. + // if (platform == 'google' && + // pb.authStore.isValid && + // pb.authStore.token.isNotEmpty && + // pb.authStore.model is RecordModel) { + // final recovered = RecordAuth( + // token: pb.authStore.token, + // record: pb.authStore.model as RecordModel, + // meta: const {}, + // ); + // try { + // await ref + // .read(authUseCaseProvider.notifier) + // .googleAndAppleAuthencation(recovered); + // } catch (_) {} + // if (context.mounted) { + // context.go('/myhomepage'); + // } + // return; + // } + // UAE PASS user journey §8 & §9: error vs user cancel + final msg = e.toString().toLowerCase(); + + String? extractedErrorCode; + // When the PocketBase SDK throws, the OAuth error code is often present + // inside the ClientException response payload, even if e.toString() + // doesn't include it. + if (e is ClientException) { + final data = e.response['data']; + if (data is Map) { + extractedErrorCode = data['error']?.toString(); + } + extractedErrorCode ??= e.response['error']?.toString(); + } + + // Extra best-effort extraction from the string form. + extractedErrorCode ??= (() { + final match = RegExp(r'error[^a-zA-Z0-9_]*([a-zA-Z0-9_]+)', + caseSensitive: false) + .firstMatch(e.toString()); + return match?.group(1); + })(); + + // Extra best-effort extraction from a generic response payload. + // Some SDK exceptions might not be `ClientException` but still carry a + // `response` map with `data.error`. + extractedErrorCode ??= (() { + try { + final dynamic dyn = e; + final resp = dyn.response; + if (resp is Map) { + final data = resp['data']; + if (data is Map && data['error'] != null) { + return data['error'].toString(); + } + if (resp['error'] != null) return resp['error'].toString(); + } + } catch (_) {} + return null; + })(); + + final errorCode = extractedErrorCode?.toLowerCase(); + print('errorCode $errorCode'); + + final looksLikeMissingAuthCode = msg.contains('missing code') || + msg.contains('missing') && msg.contains('code') || + msg.contains('bad redirect'); + + final isCancel = errorCode == 'invalid_request' || + errorCode == 'login_required' || + errorCode == 'access_denied' || + errorCode == 'cancelledonapp' || + // Fallback to matching the known substring. + msg.contains('cancelledonapp') || + msg.contains('login_required') || + msg.contains('access_denied') || + // PocketBase SDK may throw generic "bad redirect ... missing code" + // even when the user cancels in UAE PASS app. + looksLikeMissingAuthCode; + final isSop1Unverified = msg.contains('not eligible') || + msg.contains('visitor account') || + msg.contains('not upgraded'); + final isExistingUsersOnly = msg.contains('only for registered users'); + final isUaeNationalsOnly = msg.contains('only accesible to uae nationals') || + msg.contains('only accessible to uae nationals'); + if (platform == 'oidc') { + String content = context.translate( + 'Something went wrong during the login, please try again later!', + 'حدث خطأ ما أثناء تسجيل الدخول، يرجى المحاولة مرة أخرى لاحقًا!', + ); + String actionLabel = context.translate('Try Again', 'أعد المحاولة'); + bool showErrorTitle = false; + + if (isCancel) { + content = context.translate( + 'User cancelled the login', + 'قام المستخدم بإلغاء تسجيل الدخول', + ); + actionLabel = context.translate('Close', 'إغلاق'); + } else if (isSop1Unverified) { + content = context.translate( + 'You are not eligible to access this service. Your account is either not upgraded or you have a visitor account. Please contact FCSC to access the services.', + 'أنت غير مؤهل للوصول إلى هذه الخدمة. إما أن حسابك لم تتم ترقيته أو لديك حساب زائر. يرجى التواصل مع FCSC للوصول إلى الخدمات.', + ); + actionLabel = context.translate('Close', 'إغلاق'); + showErrorTitle = true; + } else if (isExistingUsersOnly) { + content = context.translate( + 'This service is only for registered users, please contact FCSC in order to access the services.', + 'هذه الخدمة مخصصة فقط للمستخدمين المسجلين، يرجى التواصل مع FCSC لتتمكن من الوصول إلى الخدمات.', + ); + actionLabel = context.translate('Close', 'إغلاق'); + showErrorTitle = true; + } else if (isUaeNationalsOnly) { + content = context.translate( + 'This service is only accesible to UAE Nationals', + 'هذه الخدمة متاحة فقط لمواطني دولة الإمارات العربية المتحدة.', + ); + actionLabel = context.translate('Close', 'إغلاق'); + showErrorTitle = true; + } + + await _showUaePassStyledDialog( + context: context, + message: content, + actionLabel: actionLabel, + showErrorTitle: showErrorTitle, + ); + if (context.mounted) context.go('/login'); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.translate( + 'Something went wrong during the login, please try again later!', + 'حدث خطأ ما أثناء تسجيل الدخول، يرجى المحاولة مرة أخرى لاحقًا!', + ), + ), + ), + ); + } + } + } + + Future loginCountApi(String userId) async { + if (userId.isNotEmpty) { + final url = Uri.parse('${apiUrl}api/login_success?id=$userId'); + + try { + final response = await http + .get(url, headers: {'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A'}); + + if (response.statusCode == 200) { + print("Login success API call successful: ${response.body}"); + final count = jsonDecode(response.body); + loginCount = count["login_count"]; + print(loginCount); + } else { + print( + "Failed to call login success API. Status code: ${response.statusCode}"); + } + } catch (e) { + print("Error calling login success API: $e"); + } + + await saveUserId(userId, loginCount); + } + } + + Future updateOAuthUserDetails( + String userId, metaDetails, WidgetRef ref, token) async { + print('Update token : ${token}'); + // final url = "${pb.baseUrl}api/collections/users/records/$userId"; + + try { + // // Authenticate admin (Persist session globally instead) + // await pb.admins.authWithPassword( + // 'pb@venbainfotech.com', + // 'pb@venbainfotech.com', + // ); + // final auth = ref.watch(authProvider); + + // await pb.admins.authWithPassword( + // auth.email, + // auth.password, + // ); + + // final themeService = ThemeBaseService(); + // final adminToken = await themeService.getAdminToken( + // email: auth.email, + // password: auth.password, + // ); + final userDetails = metaDetails['rawUser']; + print('oauthDetails $userDetails'); + print("Updating user with ID: ${pb.authStore.model.id}"); + final userId = pb.authStore.model.id; + final response = await pb.collection('users').update( + userId, + body: { + "user_mail_verify": true, + "reviewed": true, + "role": 'user', + "uname": userDetails['name'], + "status": 'Approved', + "is_oauth_login": 1, + }, + headers: { + 'Content-Type': 'application/json', // Add this + 'Authorization': 'Bearer $token', // Correct way to send auth token + }, + ); + print('User response $response'); + // final response = await http.patch( + // Uri.parse(url), + // headers: { + // 'Content-Type': 'application/json', // Add this + // 'Authorization': PocketBaseService.authStore.token, // Correct way to send auth token + // }, + // body: jsonEncode({ + // "user_mail_verify": true, + // "reviewed": true, + // "role": 'user', + // "uname": userDetails['name'], + // "status": 'Approved', + // "is_oauth_login": 1, + // }), + // ); + + // if (response.statusCode == 200) { + // print("✅ Device token updated successfully!"); + // } else { + // print("❌ Failed to update device token: ${response.body}"); + // } + } catch (e) { + print("🚨 Error updating device token: $e"); + } + } + + // SessionEntity _sessionEntityFromPocketbaseRecord(RecordAuth ra) { + // final decodedToken = JwtDecoder.decode(ra.token); + // final expInSeconds = decodedToken['exp']; + // final expInMilliseconds = expInSeconds * 1000; + // final expiresOn = DateTime.fromMillisecondsSinceEpoch(expInMilliseconds); + // final se = SessionEntity( + // id: ra.record!.id, + // expiresOn: expiresOn, + // token: ra.token, + // modelJson: ra.record!.toJson(), + // ); + // return se; + // } + + // Future googleLogin(BuildContext context,WidgetRef ref) async { + // try { + // final authData = await pb.collection('users').authWithOAuth2('apple', (url) async { + // await launchUrl(url); + // }); + // print(authData); + // // ✅ Check if authData is valid + // if (authData.token.isNotEmpty && authData.record != null) { + // + // + // print("✅ Login Successful"); + // print(authData.record); + // + // + // final userDetails = authData.record; + // final userId = userDetails?.id; + // final email = userDetails?.data['email'] ?? ''; + // final isProfileComplete = userDetails?.data['is_profile_completed'] ?? ''; + // final loginCount = userDetails?.data['login_count'] ?? ''; + // // final verified = userDetails?.data['verified'] ?? ''; + // + // final se = _sessionEntityFromPocketbaseRecord(authData); + // await _LocalAuthRepo.storeLocally(se); + // + // print("User ID: ${userId}"); + // print("Email: ${email}"); + // print("Email: ${isProfileComplete}"); + // print("Email: ${loginCount}"); + // + // await saveUserId(userId!, loginCount); + // // await getDeviceToken(context, id); + // await initializeTheme(context,ref,userId); + // + // // Extract intendedPath from query parameters (if coming from redirect) + // final String? intendedPath = + // GoRouterState.of(context).uri.queryParameters['intendedPath']; + // print("Extracted intendedPath after login: $intendedPath"); + // + // if (isProfileComplete) { + // print('Redirecting to Home / Intended Path'); + // // **Set Default Locale in Provider** + // if (preferredLanguage == 'ar') { + // ref.read(localeProvider.notifier).setLocale(const Locale('ar')); + // } else { + // ref.read(localeProvider.notifier).setLocale(const Locale('en')); + // } + // context.go(intendedPath ?? '/myhomepage'); + // } else { + // print('Redirecting to Profile Setup'); + // + // context.go('/profile/$userId'); + // + // } + // } else { + // print("❌ Login failed: Invalid authData"); + // } + // } catch (e) { + // print("❌ Error during Google login: $e"); + // ScaffoldMessenger.of(context).showSnackBar( + // SnackBar(content: Text("Login failed. Please try again.")), + // ); + // } + // } + + Future checkUserWithUhid({ + required String code, + required ref, + // required String email, + // required String uuid, + required BuildContext context, + // required userInfo, + }) async { + final url = Uri.parse('${apiUrl}api/checkUserWithUhid'); + + final headers = { + 'Content-Type': 'application/json', + 'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A' + }; + + final body = jsonEncode({ + // 'email': email, + // 'uuid': uuid, + 'code': code + }); + + try { + final response = await http.post(url, headers: headers, body: body); + + if (response.statusCode == 200) { + // final data = jsonDecode(response.body); + final apiParam = { + "data": { + "avatar": "", + "collectionId": "_pb_users_auth_", + "collectionName": "users", + "country_region": "", + "created": "2025-09-27 04:39:48.639Z", + "device_token": "", + "dob": "", + "emailVisibility": false, + "full_name": "", + "id": "iwxvax2m3ra8vry", + "is_oauth_login": 0, + "is_profile_completed": false, + "language": "", + "login_count": 4, + "pushed_notification": null, + "pw_reset_token": "", + "pw_reset_token_expiry": "", + "reviewed": false, + "role": "", + "status": "", + "theme": "", + "uname": "", + "updated": "2025-11-25 09:12:01.746Z", + "user_mail_verify": false, + "username": "users45431", + "verified": true + }, + "expiresOn": 1765271521, + "message": "User found by email and UUID updated", + "status": "success", + "token": + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3NjUyNzE1MjEsImlkIjoiaXd4dmF4Mm0zcmE4dnJ5IiwidHlwZSI6ImF1dGhSZWNvcmQifQ.jSXeGByFWsPTv70nW2YvahZi8fYCzfibF8nWqhKYPA0" + }; + print('✅ Success: $apiParam'); + if (apiParam['token'] != null && + apiParam['token'].toString().isNotEmpty && + apiParam['status'] == 'success') { + print("✔ Token exists & login success!"); + + // final metaDetails = authData.meta; + final userDetails = apiParam['data'] as Map; + final token = apiParam['token']; + final userId = userDetails['id']; + final email = userDetails['email'] ?? ''; + final uname = userDetails['uname'] ?? ''; + final isProfileComplete = + userDetails['is_profile_completed'] ?? false; + final loginCount = userDetails['login_count'] ?? 0; + // final expiresOn = apiParam['expiresOn']; + + print("User ID: ${userId}"); + print("Email: ${email}"); + print("Email: ${isProfileComplete}"); + print("Email: ${loginCount}"); + + final session = await context.loaderWithErrorDialog( + () => ref + .read( + authUseCaseProvider.notifier, + ) + .googleAndAppleAuthencation(apiParam), + errorDialogBuilder: ( + error, [ + StackTrace? stackTrace, + ]) { + if (error == LoginError.invalidEmailPw) { + return context.simpleDialog( + title: context.translate( + 'Incorrect Credentials', + 'أوراق غير صحيحة', + ), + content: context.translate( + 'Your email or password is invalid. Please try again.', + 'بريدك الإلكتروني أو كلمة المرور غير صالحة. حاول مرة اخرى.', + ), + ); + } + return context.simpleDialog(); + }, + ); + + print("sessionTST - $session"); + + await loginCountApi(userId!); + await updateOAuthUserDetails(userId!, uname, ref, token); + // await saveUserId(userId!, loginCount); + await getDeviceToken(context, userId, ref, token); + await initializeTheme(context, ref, userId); + + // final se = _sessionEntityFromPocketbaseRecord(authData); + // await _LocalAuthRepo.storeLocally(se); + + // Extract intendedPath from query parameters (if coming from redirect) + final String? intendedPath = + GoRouterState.of(context).uri.queryParameters['intendedPath']; + print("Extracted intendedPath after login: $intendedPath"); + + if (isProfileComplete) { + print('Redirecting to Home / Intended Path'); + // **Set Default Locale in Provider** + if (preferredLanguage == 'ar') { + ref.read(localeProvider.notifier).setLocale(const Locale('ar')); + } else { + ref.read(localeProvider.notifier).setLocale(const Locale('en')); + } + + context.go(intendedPath ?? '/myhomepage'); + } else { + print('Redirecting to Profile Setup'); + + context.go('/profile/$userId'); + } + } else { + print("❌ Login failed: Invalid authData"); + } + } else { + GoRouter.of(context).go('/fcscprofilelinking', extra: userInfo); + print('❌ Error: ${response.statusCode} - ${response.body}'); + } + } catch (e) { + print('⚠️ Exception: $e'); + } + } + + String buildUaepassAuthorizationUrl() { + const String authorizeEndpoint = + "https://stg-id.uaepass.ae/idshub/authorize"; + const String clientId = "fcsc_uae_stat_mob_stage"; + const String scope = "urn:uae:digitalid:profile:general"; + const String redirectUri = "https://pb.venbait.in/api/oauth2-redirect"; + const String acrValues = + "urn:safelayer:tws:policies:authentication:level:low"; + + // Generate random state (recommended) + final String state = DateTime.now().millisecondsSinceEpoch.toString(); + + final Uri uri = Uri.parse(authorizeEndpoint).replace( + queryParameters: { + "response_type": "code", + "client_id": clientId, + "scope": scope, + "redirect_uri": redirectUri, + "state": state, + "acr_values": acrValues, + }, + ); + print('UAEPASS URL : $uri'); + return uri.toString(); + } + + // Future openUaepassLogin(BuildContext context) async { + // final authUrl = 'https://pb.venbait.in/api/oauth2/uaepass/auth'; + // + // final uri = Uri.parse(authUrl); + // + // if (!await launchUrl( + // uri, + // mode: LaunchMode.externalApplication, + // )) { + // throw 'Could not launch UAE PASS URL'; + // } + // } + + Future openUaepassLogin(BuildContext context) async { + final authUrl = buildUaepassAuthorizationUrl(); // Now this exists + + print('UAE Pass :$authUrl'); + + final uri = Uri.parse(authUrl); + + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + throw 'Could not launch UAE PASS URL'; + } + + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (_) => Scaffold( + // appBar: AppBar(title: const Text("UAE PASS Login")), + // body: InAppWebView( + // initialUrlRequest: URLRequest( + // url: WebUri.uri(Uri.parse(authUrl)), + // ), + // initialOptions: InAppWebViewGroupOptions( + // crossPlatform: InAppWebViewOptions( + // useShouldOverrideUrlLoading: true, + // javaScriptEnabled: true, + // ), + // android: AndroidInAppWebViewOptions(), + // ios: IOSInAppWebViewOptions(), + // ), + // ), + // ), + // ), + // ); + } + + Future> fetchUaepassToken(String code) async { + const String tokenUrl = "https://stg-id.uaepass.ae/idshub/token"; + + const String clientId = "fcsc_uae_stat_mob_stage"; + const String clientSecret = "BcamcqPfkrZog"; // For POC both are same + const String redirectUri = + "https://pb.venbait.in/api/oauth2-redirect"; // use your redirect URI + + // Encode Basic Auth: base64(clientId:clientSecret) + final String basicAuth = + 'Basic ${base64Encode(utf8.encode("$clientId:$clientSecret"))}'; + + final response = await http.post( + Uri.parse(tokenUrl), + headers: { + "Authorization": basicAuth, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: { + "grant_type": "authorization_code", + "redirect_uri": redirectUri, + "code": code, + }, + ); + + print("🔍 UAE PASS TOKEN RESPONSE → ${response.statusCode}"); + print("📩 Response Body → ${response.body}"); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception("Failed to fetch token: ${response.body}"); + } + } + + Future _callUaepassTokenAPI( + BuildContext context, WidgetRef ref, String code) async { + try { + final String safeCode = (code is String) ? code : code?.toString() ?? ''; + + print("🔐 Calling UAE PASS Token API with code: $safeCode"); + + // TODO: Implement your API call here + // Example: + // final token = await ref.read(authProvider.notifier).loginWithUaepass(code); + + // final tokenData = await fetchUaepassToken(code); + // + // final accessToken = tokenData['access_token']; + // final refreshToken = tokenData['refresh_token']; + // print("✅ Access Token: $accessToken"); + // print("🔄 Refresh Token: $refreshToken"); + // final userInfo = await fetchUaepassUserInfo(accessToken); + // + // print("👤 UAE PASS User Info:"); + // print(userInfo); + // + await checkUserWithUhid( + code: safeCode, + ref: ref, + context: context, + ); + print("✅ Token received and processed successfully"); + } catch (e) { + print("❌ UAE PASS token exchange failed: $e"); + } + } + + Future> fetchUaepassUserInfo(String accessToken) async { + const String userInfoUrl = "https://stg-id.uaepass.ae/idshub/userinfo"; + + final response = await http.get( + Uri.parse(userInfoUrl), + headers: { + "Authorization": "Bearer $accessToken", + "Accept": "application/json", + }, + ); + + print("🔍 USER INFO RESPONSE → ${response.statusCode}"); + print("📩 Response Body → ${response.body}"); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception("Failed to fetch user info: ${response.body}"); + } + } + + Future loginWithProviderAllInOne(String provider) async { + try { + // NOTE: no redirectUrl named parameter here + final authData = + await pb.collection('users').authWithOAuth2(provider, (url) async { + await launchUrl(url); + }); + print('PB token: ${pb.authStore.token}'); + print('User: ${pb.authStore.model}'); + } catch (e) { + print('OAuth error: $e'); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + double screenHeight = MediaQuery.of(context).size.height; + double screenWidth = MediaQuery.of(context).size.width; + final isTablet = screenWidth > 600; + final uaePass = useMemoized(() => UaePass()); + final isInit = useState(false); + final params = GoRouterState.of(context).uri.queryParameters; + final uaePassCode = params['uaeCode']; + + useEffect(() { + if (uaePassCode != null) { + print("🚀 UAE PASS code received: $uaePassCode"); + _callUaepassTokenAPI(context, ref, uaePassCode); + } + return null; + }, [uaePassCode]); + + // Initialize UAE PASS once when widget loads + useEffect(() { + Future.microtask(() async { + // await uaePass.setUpSandbox(); + await uaePass.setUpEnvironment( + clientId: "fcsc_uae_stat_mob_stage", + clientSecret: "BcamcqPfkrZog", + isProduction: false, + urlScheme: "fcscUaeStats", // fallback for Android custom scheme + redirectUri: "https://pb.venbait.in/api/oauth2-redirect", + scope: "urn:uae:digitalid:profile", + language: "en", + ); // or setUpEnvironment(...) for prod + isInit.value = true; + }); + return null; + }, []); + + /// ------------------------------------------------------- + /// POCKETBASE OAUTH AUTO-CLOSE (RESUME DETECTION) + /// ------------------------------------------------------- + // useEffect(() { + // final observer = _OAuthLifecycleObserver( + // pb: pb, + // context: context, + // ); + // + // WidgetsBinding.instance.addObserver(observer); + // return () => WidgetsBinding.instance.removeObserver(observer); + // }, []); + + if (!isInit.value) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + // useEffect(() { + // _uaePass.setUpSandbox(); // equivalent to initState() + // return null; // no cleanup needed + // }, const []); + + ref.listen>(connectivityProvider, (previous, hasInternet) { + if (hasInternet.value == false) { + context.push('/internetcheck'); + } + }); + + final themeMode = ref.read(themeProvider); + final isDarkTheme = themeMode == ThemeMode.dark || + (themeMode == ThemeMode.system && + MediaQuery.of(context).platformBrightness == Brightness.dark); + + // useEffect(() { + // // Wait until first frame is rendered + // Future.microtask(() { + // checkAndShowDialogOnce(context); + // }); + // return null; + // }, []); + + // if(!isWeb){ + // WidgetsBinding.instance.addPostFrameCallback((_) { + // AppLinksHelper.checkAndShowDialogOnce(context); + // }); + // } + + final hasValidated = useState(false); + final isUaePassFlowActive = useState(false); + final locale = ref.watch(localeProvider); + final emailCtl = useTextEditingController(); + final pwCtl = useTextEditingController(); + useEffect(() { + emailCtl.clear(); + pwCtl.clear(); + formKey.currentState?.reset(); + + final hasFromPage = fromPage != null && fromPage!.isNotEmpty; + final info = userInfo; + if (hasFromPage && info != null && info.isNotEmpty) { + final raw = info['email'] ?? info['Email']; + final emailStr = raw?.toString().trim(); + if (emailStr != null && emailStr.isNotEmpty) { + emailCtl.text = emailStr; + } + } + return null; + }, [fromPage, userInfo]); + final forgotPwBtn = TextButton( + onPressed: () async { + final forgotPwFormKey = GlobalKey(); + final email = await showDialog( + context: context, + builder: (dialogCtx) => SizedBox( + // width: 850, // Increase dialog width + child: AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), // Rounded corners + ), + elevation: 10, + shadowColor: Colors.black12, + title: Text( + context.translate( + 'Reset Password', + 'إعادة تعيين كلمة المرور', + ), + style: TextStyle( + fontFamily: context.translate( + 'Roboto', + 'NotoKufi', + ), + fontSize: locale?.languageCode == 'ar' ? 18 : 20, + color: isDarkTheme ? Color(0xFFFFFFFF) : Color(0xFF898C81), + fontWeight: FontWeight.w600), + ), + content: Form( + key: forgotPwFormKey, + child: TextFormField( + validator: emailValidation(), + controller: emailCtl, + decoration: InputDecoration( + errorStyle: TextStyle( + fontFamily: context.translate( + 'Roboto', + 'NotoKufi', + ), + color: Color(0xFFD83731), + ), + labelText: context.translate( + 'Email address', + 'عنوان البريد الإلكتروني', + ), + labelStyle: TextStyle( + fontFamily: context.translate( + 'Roboto', + 'NotoKufi', + ), + fontSize: locale?.languageCode == 'ar' ? 13 : 16, + color: isDarkTheme + ? Color(0xFFFFFFFF) + : Color(0xFF898C81), // For label text color + // fontWeight: FontWeight.w600, + ), + // ✅ Default Border + + // ✅ Bottom border (no outline) + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: isDarkTheme + ? Colors.white + : const Color(0xFF898C81), + width: 1, + ), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Color(0xFFB68A34), + width: 2, + ), + ), + errorBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Color(0xFFD83731), + width: 1, + ), + ), + focusedErrorBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Color(0xFFD83731), + width: 2, + ), + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: Navigator.of(dialogCtx, rootNavigator: true).pop, + style: TextButton.styleFrom( + // backgroundColor: Colors.white, // Background color + foregroundColor: Color(0xFF92722A), // Font (text) color + side: BorderSide( + color: Color(0xFF92722A)), // Border outline color + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(8), // Optional: Rounded corners + ), + padding: EdgeInsets.symmetric( + horizontal: 10, vertical: 10), // Optional: Padding + ), + child: Text( + context.translate( + 'Return', + 'يعود', + ), + style: TextStyle( + fontSize: context.translate(12, 10), + fontFamily: context.translate( + 'Roboto', + 'NotoKufi', + ), + ), + ), + ), + ElevatedButton( + onPressed: () { + final isValid = forgotPwFormKey.currentState!.validate(); + if (!isValid) return; + Navigator.of(dialogCtx, rootNavigator: true).pop( + emailCtl.text, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFFB68A34), + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric( + horizontal: 10, vertical: 10), // Optional: Adjusts size + shape: RoundedRectangleBorder( + // Optional: Adds rounded corners + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + context.translate( + 'Send Email', + 'إرسال بريد إلكتروني', + ), + style: TextStyle( + fontSize: context.translate(12, 10), + fontFamily: context.translate( + 'Roboto', + 'NotoKufi', + ), + ), + ), + ), + ], + ), + ), + ); + if (email == null) return; + if (!context.mounted) return; + + // await context.loaderWithErrorDialog(() async { + // await forgotPasswordMail(email); + // }); + String status = ''; + await context.loaderWithErrorDialog(() async { + status = await forgotPasswordMail(email); + }); + if (!context.mounted) return; + if (status == 'success') { + context.simpleDialog( + title: context.translate( + 'Success!', + 'نجاح', + ), + content: context.translate( + 'An email has been sent to $email with further details.', + 'تم إرسال بريد إلكتروني إلى $email يتضمن المزيد من التفاصيل.', + ), + ); + } else { + context.simpleDialog( + title: context.translate( + 'Failed!', + 'نجاح', + ), + content: context.translate( + 'Password reset is not available for accounts signed in with Apple or Google.', + 'لا تتوفر إمكانية إعادة تعيين كلمة المرور للحسابات التي تم تسجيل الدخول إليها باستخدام Apple أو Google.', + ), + ); + } + }, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + // maximumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + overlayColor: Colors.white), + child: Text( + context.translate( + 'Forgot Password?', + 'هل نسيت كلمة السر؟', + ), + style: TextStyle( + fontFamily: context.translate( + 'Roboto', + 'NotoKufi', + ), + fontWeight: FontWeight.bold, + fontSize: locale?.languageCode == 'ar' ? 12 : 14, + color: Color(0xFF8A8C81), + ), + ), + ); + final loginBtn = SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () async { + hasValidated.value = true; + final isValid = formKey.currentState!.validate(); + if (!isValid) return; + var mailID = emailCtl.text; + var email = mailID.toLowerCase(); + final session = await context.loaderWithErrorDialog( + () => ref + .read( + authUseCaseProvider.notifier, + ) + .login( + email, + pwCtl.text, + ), + errorDialogBuilder: ( + error, [ + StackTrace? stackTrace, + ]) { + if (error == LoginError.invalidEmailPw) { + return context.simpleDialog( + title: context.translate( + 'Incorrect Credentials', + 'أوراق غير صحيحة', + ), + content: context.translate( + 'Your email or password is invalid. Please try again.', + 'بريدك الإلكتروني أو كلمة المرور غير صالحة. حاول مرة اخرى.', + ), + ); + } + // if (error == LoginError.emailAddressNotVerified) { + // return context.simpleDialog( + // title: context.translate( + // 'Verification Error', + // 'خطأ التحقق', + // ), + // content: context.translate( + // '${emailCtl.text} is not a verified email address. Please check your email for a verification link.', + // '${emailCtl.text} ليس عنوان بريد إلكتروني تم التحقق منه. يرجى التحقق من بريدك الإلكتروني للحصول على رابط التحقق.', + // ), + // extraAction: ElevatedButton( + // onPressed: () async { + // Navigator.of( + // context, + // rootNavigator: true, + // ).pop(); + // await context.loaderWithErrorDialog( + // () => ref + // .read(authUseCaseProvider.notifier) + // .requestVerificationEmail(emailCtl.text), + // ); + // if (!context.mounted) return; + // context.simpleDialog( + // title: 'Email Re-sent', + // content: + // 'We\'ve sent you the verification email at ${emailCtl.text} again.', + // ); + // }, + // child: Text( + // context.translate( + // 'I did not receive an email', + // 'لم أتلق بريدًا إلكترونيًا', + // ), + // ), + // ), + // ); + // } + return context.simpleDialog(); + }, + ); + + if (!context.mounted || session == null) return; + final userId = session.id; + + // YES path: merge normal account into UAE PASS OAuth record, then drop normal user. + // Skip FCSC login_success / device token on the temporary normal session. + if (cameFromLinking) { + await _mergeUaepassIntoNormalUser( + normalUserId: userId, + ref: ref, + context: context, + ); + return; + } + + if (userId.isNotEmpty) { + print('EMAIL - $email '); + print('PASSWORD - ${pwCtl.text}'); + + // USE Dynamic USER EMail and Password + + // var password = pwCtl.text; + + // Save to AuthProvider + // ref.read(authProvider.notifier).setCredentials(email, password); + + // final session = await context.loaderWithErrorDialog( + // () => + // ref.read(authUseCaseProvider.notifier).login(email, password), + // ); + // + // final auth = ref.watch(authProvider); + // + // print("EMAIL - ${auth.email}"); + // print("PASSWORD - ${auth.password}"); + + final url = Uri.parse('${apiUrl}api/login_success?id=$userId'); + + try { + final response = await http.get(url, + headers: {'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A'}); + + if (response.statusCode == 200) { + print("Login success API call successful: ${response.body}"); + print("LOginBtn"); + final count = jsonDecode(response.body); + loginCount = + count["login_count"]; // Directly assign it as an int + print(loginCount); + } else { + print( + "Failed to call login success API. Status code: ${response.statusCode}"); + } + } catch (e) { + print("Error calling login success API: $e"); + } + + await saveUserId(userId, loginCount); + await getDeviceToken( + context, userId, ref, PocketBaseService.authStore.token); + await initializeTheme(context, ref, userId); + } + try { + final bool isProfileComplete = await profileStatus(userId, ref); + print("Is Profile Complete: $isProfileComplete"); + + // Extract intendedPath from query parameters (if coming from redirect) + // final String? intendedPath = + // GoRouterState.of(context).uri.queryParameters['intendedPath']; + // print("Extracted intendedPath after login: $intendedPath"); + + if (!context.mounted) return; + + if (isProfileComplete) { + print('Redirecting to Home / Intended Path'); + // After UAE PASS linking, honour the language used on the UAE PASS button / app toggle. + if (UaePassOAuthState.instance.pendingUiLocales == 'ar' || + UaePassOAuthState.instance.pendingUiLocales == 'en') { + applyUaePassDashboardLocale(ref, context); + } else if (preferredLanguage == 'ar') { + ref.read(localeProvider.notifier).setLocale(const Locale('ar')); + } else { + ref.read(localeProvider.notifier).setLocale(const Locale('en')); + } + context.go('/myhomepage'); + } else { + print('Redirecting to Profile Setup'); + if (UaePassOAuthState.instance.pendingUiLocales == 'ar' || + UaePassOAuthState.instance.pendingUiLocales == 'en') { + applyUaePassDashboardLocale(ref, context); + } + if (userId.isNotEmpty) { + context.go('/profile/$userId'); + } else { + print('Error: userId is null or empty.'); + } + } + } catch (e) { + // Handle specific errors based on their message + if (e.toString().contains('Admin not approved')) { + context.simpleDialog( + title: context.translate( + 'Admin Approval Required', 'موافقة المسؤول مطلوبة'), + content: context.translate( + 'Your account has not been approved by the admin.', + 'لم تتم الموافقة على حسابك من قبل المسؤول.', + ), + ); + } else if (e.toString().contains('Email not verified')) { + context.simpleDialog( + title: context.translate( + 'Email Not Verified', 'البريد الإلكتروني غير مُحقق'), + content: context.translate( + 'Your email address is not verified. Please check your email.', + 'عنوان بريدك الإلكتروني غير مُحقق. يرجى التحقق من بريدك الإلكتروني.', + ), + ); + } else { + print('Unexpected error: $e'); + } + } + + // context.go('/${context.language}/${BottomNavBarItem.home.routePath}'); + }, + style: ButtonStyle( + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(vertical: 16.5), + ), + textStyle: WidgetStatePropertyAll( + TextStyle( + fontFamily: context.translate( + 'Roboto', + 'NotoKufi', + ), + fontSize: locale?.languageCode == 'ar' ? 14 : 16, + fontWeight: FontWeight.w500, + ), + ), + backgroundColor: WidgetStatePropertyAll( + // isDarkTheme ? Color(0xFFB68A34) : Color(0xFFB68A34), + Color(0xFFB68A34), + // MyTheme.topicColor(IndicatorTopic.social), + ), + // surfaceTintColor: MaterialStatePropertyAll( + // MyTheme.economy[800], + // ), + foregroundColor: const WidgetStatePropertyAll( + Colors.white, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + context.translate('Link account with UAE PASS', + 'ربط الحساب مع بطاقة الهوية الإماراتية'), + style: TextStyle( + fontFamily: context.translate( + 'Roboto', + 'NotoKufi', + ), + fontSize: locale?.languageCode == 'ar' ? 12 : 16, + fontWeight: FontWeight.w500, + color: isDarkTheme ? Colors.white : Colors.white, + ), + ), + + 6.horizontalSpace, + Icon( + Icons.chevron_right_outlined, + color: isDarkTheme + ? Colors.white + : Colors.white, // Set your desired color here + ) + ], + ), + ), + ); + final dontHaveAnAccountRegisterBtn = TextButton( + onPressed: () => {context.push('/register')}, + // onPressed: () => + // {context.push('/forgot-password/surendarsuri30@gmail.com')}, + child: Text.rich( + textAlign: TextAlign.center, + TextSpan( + style: TextStyle( + fontFamily: context.translate( + 'Roboto', + 'NotoKufi', + ), + fontSize: locale?.languageCode == 'ar' ? 12 : 14, + fontWeight: FontWeight.bold, + ), + children: [ + TextSpan( + text: context.translate( + 'Don\'t have an account? ', + 'ليس لديك حساب؟', + ), + style: TextStyle( + fontSize: locale?.languageCode == 'ar' ? 12 : 14, + color: isDarkTheme ? Color(0xFFFFFFFF) : Color(0xFF898C81), + ), + ), + const TextSpan( + text: ' ', + ), + TextSpan( + text: context.translate( + 'Register Now', + 'سجل الان', + ), + style: TextStyle( + fontSize: locale?.languageCode == 'ar' ? 12 : 14, + color: isDarkTheme ? Color(0xFFB68A35) : Color(0xFF985400), + ), + ), + ], + ), + ), + ); + final form = Form( + key: formKey, + child: Column( + children: [ + ThemedFormField( + hintText: context.translate( + 'Email', + 'بريد إلكتروني', + ), + // hintColor: isDarkTheme ? Color(0XFFEEEEEE) : Color(0xFFC3C6CB), + backgroundColor: isDarkTheme ? Color(0xFF000000) : Colors.white, + // borderColor: Color(0xFFB68A34), + // errorBorderColor: isDarkTheme ? const Color(0xFFFFA100) : const Color(0xFFB22222), + // errorTextColor: isDarkTheme ? const Color(0xFFFFA100) : const Color(0xFFB22222), + validator: (value) { + final emailRegex = + r'^[a-zA-Z0-9àèìòùÀÈÌÒÙéàç~!#$%^&*()_+=-{}|;,.?<>]+@[a-zA-Z0-9àèìòùÀÈÌÒÙéàç~!#$%^&*()_+=-{}|;,.?<>]+\.[a-zA-Z]{2,}$'; + if (value == null || value.isEmpty) { + return context.translate('Required', 'مطلوب'); + } else if (!RegExp(emailRegex).hasMatch(value)) { + return AppLocalizations.of(context)!.invalid_email; + } + return null; + }, + // imgPath: isDarkTheme ? MiscIconAssetPath.personDark : MiscIconAssetPath.personLight, + imgPath: MiscIconAssetPath.person, + controller: emailCtl, + ), + 15.verticalSpace, + ThemedFormField( + validator: (text) { + if (text == null || text.isEmpty) { + return context.translate('Required', 'مطلوب'); + } else if (text.length < 8) { + return AppLocalizations.of(context)!.only8charac; + } + return FieldValidator.password(minLength: 8)(text); + }, + hintText: context.translate( + 'Password', + 'كلمة المرور', + ), + // hintColor: isDarkTheme ? Color(0XFFEEEEEE) : Color(0xFFC3C6CB), + backgroundColor: isDarkTheme ? Color(0xFF000000) : Colors.white, + // borderColor: isDarkTheme ? Colors.white : MyTheme.topicColor(IndicatorTopic.economy).shade300, + // errorBorderColor: isDarkTheme ? const Color(0xFFFFA100) : const Color(0xFFB22222), + // errorTextColor: isDarkTheme ? const Color(0xFFFFA100) : const Color(0xFFB22222), + // imgPath: isDarkTheme ? MiscIconAssetPath.lockDark : MiscIconAssetPath.lockLight, + imgPath: MiscIconAssetPath.lock, + controller: pwCtl, + isObscurable: true, + ), + 6.verticalSpace, + Padding( + padding: EdgeInsets.zero, + child: Align( + alignment: AlignmentDirectional.topEnd, + child: forgotPwBtn, + ), + ), + 11.verticalSpace, + dontHaveAnAccountRegisterBtn, + + 11.verticalSpace, + loginBtn, + ], + ), + ); + final helloAndPleaseLoginTexts = Column( + children: [ + SizedBox(height: screenHeight / 60), + // Title + Text( + "UAE PASS - FCSC\nProfile Linking", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: isTablet ? 36 : 30, + fontWeight: FontWeight.w400, + color: isDarkTheme ? Color(0xFFFFFFFF) : Color(0xFF000000), + ), + ), + SizedBox( + height: isTablet + ? screenHeight * 0.12 // 12% of screen for tablet + : screenHeight * 0.08, // 8% for mobile + ), + // Subtitle + Text( + "( Login with your FCSC credentials )", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: isTablet ? 18 : 16, + color: isDarkTheme ? Color(0xFFFFFFFF) : Color(0xFF000000), + fontWeight: FontWeight.w400, + ), + ), + + ], + ); + + final fcscBanner = Padding( + padding: const EdgeInsets.symmetric(horizontal: 50), + child: Image.asset( + isDarkTheme + ? BannerAssetPath.fcscLogoDark + : BannerAssetPath.fcscLogoLight, + height: 30, + ), + ); + + final scaffoldBody = SingleChildScrollView( + child: Column(children: [ + Padding( + padding: EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 3.verticalSpace, + Align( + alignment: AlignmentDirectional.topEnd, + child: MyToggle( + isOn: locale?.languageCode == 'en', + knobTextWhenOn: 'ع', + knobTextWhenOff: 'EN', + pathColorWhenOn: Color(0xFFD9D9D9), + pathColorWhenOff: Color(0xFFD9D9D9), + knobTextStyleWhenOn: TextStyle( + color: Color(0xFF010101), + fontWeight: FontWeight.bold, + ), + knobTextStyleWhenOff: TextStyle( + color: Color(0xFF010101), + fontWeight: FontWeight.bold, + ), + onTap: () { + final formState = formKey.currentState; + ref.read(localeProvider.notifier).toggleLocale(); + Future.delayed(Duration(milliseconds: 100), () { + if (hasValidated.value && + formState?.validate() == false) { + formState?.validate(); + } + }); + }, + ), + ), + 20.verticalSpace, + helloAndPleaseLoginTexts, + 15.verticalSpace, + form, + 25.verticalSpace, + Padding( + padding: EdgeInsets.symmetric( + horizontal: + isTablet ? 80 : 24, // ✅ responsive horizontal space + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Note: ', + style: TextStyle( + fontSize: isTablet ? 16 : 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF985400), + height: 1.5, + ), + ), + Expanded( + child: Text( + 'This is a one time process, your future login’s through UAE PASS will be seamless.', + style: TextStyle( + fontSize: isTablet ? 16 : 14, + fontWeight: FontWeight.w500, + color: const Color(0xFF898C81), + height: 1.5, + ), + ), + ), + ], + )), + 30.verticalSpace, + + fcscBanner, + ], + ), + ) + ]), + ); + + return PopScope( + canPop: false, // Allow back navigation only if not login screen + onPopInvokedWithResult: (didPop, result) { + if (didPop) return; + if (isUaePassFlowActive.value) return; + SystemNavigator.pop(); + // _showExitConfirmation(context); // Show exit confirmation dialog + }, + child: Scaffold( + backgroundColor: isDarkTheme ? Color(0xFF000000) : Colors.white, + body: SafeArea( + child: isInit.value + ? scaffoldBody + : const CircularProgressIndicator()), + ), + ); + } + +} + + + + diff --git a/lib/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/Detailedcompetitiveness.dart b/lib/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/Detailedcompetitiveness.dart index 5a1f4b39..99b6006e 100755 --- a/lib/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/Detailedcompetitiveness.dart +++ b/lib/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/Detailedcompetitiveness.dart @@ -34,8 +34,8 @@ class _ReportDetailPageState extends ConsumerState { late UserService _userService; late ThemeMode currentTheme; List reports = []; - int currentRank = 0; - int previousRank = 0; + num currentRank = 0; + num previousRank = 0; bool isPositive = false; Map selectedReport = {}; @@ -43,6 +43,11 @@ class _ReportDetailPageState extends ConsumerState { List gccList = []; List arabList = []; + String _formatRankValue(num value) { + if (value == 0) return '-'; + return value % 1 == 0 ? value.toInt().toString() : value.toString(); + } + @override void initState() { super.initState(); @@ -295,7 +300,7 @@ class _ReportDetailPageState extends ConsumerState { child: Column( children: [ Text( - '${AppLocalizations.of(context)!.competitiveness_rank_in} ${selectedReport['year']}', + '${selectedReport['is_score'] == false ? AppLocalizations.of(context)!.competitiveness_rank_in : AppLocalizations.of(context)!.competitiveness_score_in} ${selectedReport['year']}', style: TextStyle( fontSize: 12, color: isDarkTheme @@ -308,7 +313,7 @@ class _ReportDetailPageState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - currentRank.toString(), + _formatRankValue(currentRank), style: TextStyle( fontSize: 36, fontWeight: FontWeight.w900, @@ -317,7 +322,9 @@ class _ReportDetailPageState extends ConsumerState { : Color(0xFF000000)), ), const SizedBox(width: 6), - if (currentRank != previousRank) + if (currentRank > 0 && + previousRank > 0 && + currentRank != previousRank) Icon( isPositive ? Icons.arrow_upward @@ -348,7 +355,7 @@ class _ReportDetailPageState extends ConsumerState { ), const SizedBox(height: 6), Text( - previousRank.toString(), + _formatRankValue(previousRank), style: TextStyle( fontSize: 36, fontWeight: FontWeight.w900, diff --git a/lib/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/competitiveness.dart b/lib/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/competitiveness.dart index 2775b8dc..65f84fce 100755 --- a/lib/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/competitiveness.dart +++ b/lib/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/competitiveness.dart @@ -240,30 +240,41 @@ class _CompetitivenessState extends ConsumerState { child: Row( children: [ /// Rank Section - Column( - children: [ - Text( - AppLocalizations.of(context)!.competitiveness_rank, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - color: isDarkTheme - ? Colors.white - : Colors.black, + SizedBox( + width: 68, + child: Column( + children: [ + Text( + report['is_score'] == false + ? AppLocalizations.of( + context, + )!.competitiveness_rank + : AppLocalizations.of( + context, + )!.competitiveness_score, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: isDarkTheme + ? Colors.white + : Colors.black, + ), ), - ), - Text( - report['current_year_rank'] - .toString(), - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w900, - color: isDarkTheme - ? Colors.white - : Colors.black, + Text( + report['current_year_rank'] + .toString(), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: isDarkTheme + ? Colors.white + : Colors.black, + ), ), - ), - ], + ], + ), ), const SizedBox(width: 8), diff --git a/lib/presentation/routes/bottom_bar_routes/tab_routes/Country_Profile/DetailedCountryProfilePage.dart b/lib/presentation/routes/bottom_bar_routes/tab_routes/Country_Profile/DetailedCountryProfilePage.dart index 7882cbac..e15afe65 100755 --- a/lib/presentation/routes/bottom_bar_routes/tab_routes/Country_Profile/DetailedCountryProfilePage.dart +++ b/lib/presentation/routes/bottom_bar_routes/tab_routes/Country_Profile/DetailedCountryProfilePage.dart @@ -15,6 +15,7 @@ import 'package:uae_stat/domain/use_cases/language.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:intl/intl.dart'; class DetailedCountryProfilePage extends ConsumerStatefulWidget { final String countryName; @@ -212,70 +213,77 @@ class _DetailedCountryProfilePageState ), showBackButton: true, body: Padding( - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( - children: [ - // Country name and flag (Header Section) - Center( - child: Column( - children: [ - Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 3), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - spreadRadius: 2, - blurRadius: 6, - offset: const Offset(0, 4), - ), - ], - ), - child: ClipOval( - child: SizedBox( - width: 40, - height: 40, - child: flagUrl.isEmpty - ? const Center( - child: Icon(Icons.flag, size: 18), - ) - : flagUrl.endsWith('.svg') - ? SvgPicture.network( - flagUrl, - fit: BoxFit.cover, - placeholderBuilder: (_) => const Center( - child: SizedBox( - width: 10, - height: 10, - child: CircularProgressIndicator(strokeWidth: 1), - ), - ), - ) - : Image.network( - flagUrl, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Center( - child: Icon(Icons.flag, size: 18), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + // Country name and flag (Header Section) + Center( + child: Column( + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + spreadRadius: 2, + blurRadius: 6, + offset: const Offset(0, 4), ), + ], + ), + child: ClipOval( + child: SizedBox( + width: 40, + height: 40, + child: flagUrl.isEmpty + ? const Center( + child: Icon(Icons.flag, size: 18), + ) + : flagUrl.endsWith('.svg') + ? SvgPicture.network( + flagUrl, + fit: BoxFit.cover, + placeholderBuilder: (_) => const Center( + child: SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator( + strokeWidth: 1), + ), + ), + ) + : Image.network( + flagUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + const Center( + child: Icon(Icons.flag, size: 18), + ), + ), ), ), ), - ), - const SizedBox(height: 8), - Text( - (countryData?['country_name'] ?? '').toString().toUpperCase(), - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: isDarkTheme ? Colors.white : Color(0xFF111111), + const SizedBox(height: 8), + Text( + (countryData?['country_name'] ?? '') + .toString() + .toUpperCase(), + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: isDarkTheme ? Colors.white : Color(0xFF111111), + ), ), - ), - ], + ], + ), ), - ), - const SizedBox(height: 16), + const SizedBox(height: 16), // Government Officials (Grid Section) leaders.isEmpty @@ -303,7 +311,7 @@ class _DetailedCountryProfilePageState }, ), - const SizedBox(height: 12), // Further reduced spacing + const SizedBox(height: 12), // Further reduced spacing // Category Buttons (Button Section) statistics == null @@ -320,7 +328,7 @@ class _DetailedCountryProfilePageState children: availableCategories.map((key) { final isBilateral = key == 'bilateral_trade'; - return SizedBox( + final categoryButton = SizedBox( width: isBilateral ? double.infinity : (MediaQuery.of(context).size.width - 48) / @@ -338,12 +346,52 @@ class _DetailedCountryProfilePageState ), ), ); + + return categoryButton; }).toList(), ); }, ), - ], - ), + const SizedBox(height: 16), + ], + ), + ), + ), + if (statistics != null && statistics!.containsKey('bilateral_trade')) + Padding( + padding: EdgeInsets.only( + top: 8, + bottom: MediaQuery.of(context).padding.bottom + 8, + ), + child: _buildBilateralTradeLastUpdated(isDarkTheme), + ), + ], + ), + ), + ), + ); + } + + Widget _buildBilateralTradeLastUpdated(bool isDarkTheme) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: isDarkTheme ? const Color(0xFF222222) : const Color(0xFFF4F4F4), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + context.translate( + 'Last updated: 01 Mar 2026', + 'آخر تحديث: 01 مارس 2026', + ), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isDarkTheme ? const Color(0xFFFFFFFF) : const Color(0xFF333333), ), ), ), @@ -388,37 +436,37 @@ class __LeaderCardState extends ConsumerState<_LeaderCard> { ClipRRect( borderRadius: BorderRadius.circular(12), child: widget.imagePath.startsWith('http') && - widget.imagePath.isNotEmpty + widget.imagePath.isNotEmpty ? Image.network( - widget.imagePath, - width: 70, - height: 80, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( - width: 70, - height: 80, - color: isDarkTheme - ? const Color(0xFF222222) - : const Color(0xFFF2F2F2), - child: const Icon( - Icons.person, - size: 36, - color: Colors.grey, - ), - ), - ) + widget.imagePath, + width: 70, + height: 80, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 70, + height: 80, + color: isDarkTheme + ? const Color(0xFF222222) + : const Color(0xFFF2F2F2), + child: const Icon( + Icons.person, + size: 36, + color: Colors.grey, + ), + ), + ) : Container( - width: 70, - height: 80, - color: isDarkTheme - ? const Color(0xFF222222) - : const Color(0xFFF2F2F2), - child: const Icon( - Icons.person, - size: 36, - color: Colors.grey, - ), - ), + width: 70, + height: 80, + color: isDarkTheme + ? const Color(0xFF222222) + : const Color(0xFFF2F2F2), + child: const Icon( + Icons.person, + size: 36, + color: Colors.grey, + ), + ), ), const SizedBox(width: 8), Expanded( @@ -476,7 +524,8 @@ void showCategoryPopup(BuildContext context, String category, insetPadding: const EdgeInsets.symmetric(horizontal: 20), child: LayoutBuilder( builder: (context, constraints) { - final maxHeight = MediaQuery.of(context).size.height * 0.8; // 👈 80% height + final maxHeight = + MediaQuery.of(context).size.height * 0.8; // 👈 80% height return ConstrainedBox( constraints: BoxConstraints( @@ -497,7 +546,8 @@ void showCategoryPopup(BuildContext context, String category, ), ], ), - child: SingleChildScrollView( // ✅ SCROLL ENABLED + child: SingleChildScrollView( + // ✅ SCROLL ENABLED child: CategoryPopupContent( category: category, statistics: statistics, @@ -587,6 +637,9 @@ class CategoryPopupContent extends ConsumerStatefulWidget { } class _CategoryPopupContentState extends ConsumerState { + static final NumberFormat _tradeNumberFormat = NumberFormat('#,##0.00'); + + String _wrapLtrTradeValue(String value) => '\u2066$value\u2069'; String selectedTradeType = 'import'; bool isLoadingTrade = false; List tradeItems = []; @@ -602,7 +655,6 @@ class _CategoryPopupContentState extends ConsumerState { 'إجمالي إعادة التصدير': 'reexport', }; - @override void initState() { super.initState(); @@ -636,9 +688,9 @@ class _CategoryPopupContentState extends ConsumerState { final response = await http.get( Uri.parse( '${apiUrl}api/getBilateralTradeData' - '?country_code=$country_code' - '&statistics_id=$statisticsID' - '&language=$locale', + '?country_code=$country_code' + '&statistics_id=$statisticsID' + '&language=$locale', ), headers: {'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A'}, ); @@ -648,7 +700,7 @@ class _CategoryPopupContentState extends ConsumerState { setState(() { allTradeData = decoded; - tradeItems = decoded['Import'] ?? []; + tradeItems = _topTradeEntries(decoded[_tradeApiKey(type)] ?? []); isLoadingTrade = false; }); @@ -659,26 +711,105 @@ class _CategoryPopupContentState extends ConsumerState { } } - /// Parse API values like `AED 1.50 B` or `AED 867.50 M` into **millions**. - double parseTradeValue(String value) { - final upper = value.toUpperCase(); - double multiplier = 1.0; // default: already in millions - - if (upper.contains(' B')) { - // 1.5 B = 1500 M - multiplier = 1000.0; - } else if (upper.contains(' M')) { - multiplier = 1.0; + String _tradeApiKey(String typeKey) { + switch (typeKey) { + case 'export': + return 'Export'; + case 'reexport': + return 'Reexport'; + case 'import': + default: + return 'Import'; } - - final cleaned = value.replaceAll(RegExp(r'[^0-9.]'), ''); - final base = double.tryParse(cleaned) ?? 0; - return base * multiplier; } - /// Format a value that is already in millions, e.g. 472.5 -> "472.5M". + double _sumTradeEntries(List items) { + return items.fold( + 0, + (sum, item) => sum + parseTradeValue((item['value'] ?? '').toString()), + ); + } + + List _topTradeEntries(List items, {int limit = 5}) { + final sortedItems = List.from(items); + sortedItems.sort( + (a, b) => parseTradeValue( + (b['value'] ?? '').toString(), + ).compareTo( + parseTradeValue((a['value'] ?? '').toString()), + ), + ); + return sortedItems.take(limit).toList(); + } + + /// Parse API values like `AED 1.50 T`, `AED 1.50 B`, `AED 867.50 M`, + /// or `AED 575.21 K` into full numeric units so totals can be summed + /// accurately before being compacted again for display. + double parseTradeValue(String value) { + final match = RegExp( + r'([0-9]+(?:\.[0-9]+)?)\s*([KMBT])?', + caseSensitive: false, + ).firstMatch(value.replaceAll(',', '')); + if (match == null) return 0; + + final number = double.tryParse(match.group(1) ?? '') ?? 0; + final suffix = (match.group(2) ?? '').toUpperCase(); + + switch (suffix) { + case 'K': + return number * 1000; + case 'M': + return number * 1000000; + case 'B': + return number * 1000000000; + case 'T': + return number * 1000000000000; + default: + return number; + } + } + + + + /// Format a full numeric value into a readable K / M / B / T label. String formatTradeValue(double value) { - return '${value.toStringAsFixed(1)}M'; + if (value >= 1000000000000) { + return _wrapLtrTradeValue( + '${_tradeNumberFormat.format(value / 1000000000000)} T', + ); + } + if (value >= 1000000000) { + return _wrapLtrTradeValue( + '${_tradeNumberFormat.format(value / 1000000000)} B', + ); + } + if (value >= 1000000) { + return _wrapLtrTradeValue( + '${_tradeNumberFormat.format(value / 1000000)} M', + ); + } + if (value >= 1000) { + return _wrapLtrTradeValue( + '${_tradeNumberFormat.format(value / 1000)} K', + ); + } + return _wrapLtrTradeValue(_tradeNumberFormat.format(value)); + } + + String formatTradeDisplayValue(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return ''; + final withoutCurrency = trimmed.toUpperCase().startsWith('AED ') + ? trimmed.substring(4).trim() + : trimmed; + final match = + RegExp(r'^([0-9]+(?:\.[0-9]+)?)\s*([KMBT])$', caseSensitive: false) + .firstMatch(withoutCurrency); + if (match == null) return _wrapLtrTradeValue(withoutCurrency); + final amount = double.tryParse(match.group(1) ?? ''); + final unit = (match.group(2) ?? '').toUpperCase(); + if (amount == null || unit.isEmpty) return _wrapLtrTradeValue(withoutCurrency); + return _wrapLtrTradeValue('${_tradeNumberFormat.format(amount)} $unit'); } @override @@ -697,16 +828,22 @@ class _CategoryPopupContentState extends ConsumerState { final List items; if (widget.category == 'bilateral_trade') { - return _buildBilateralTradeUI( - widget.statistics![widget.category] as List); + return _buildBilateralTradeUI(widget.statistics![widget.category] as List); } else { items = widget.statistics![widget.category] as List; } + final sortedItems = List.from(items); + sortedItems.sort((a, b) { + final orderA = int.tryParse(a['order_id']?.toString() ?? '') ?? 1 << 30; + final orderB = int.tryParse(b['order_id']?.toString() ?? '') ?? 1 << 30; + return orderA.compareTo(orderB); + }); + return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, - children: items.asMap().entries.map((entry) { + children: sortedItems.asMap().entries.map((entry) { final index = entry.key; final item = entry.value; @@ -715,9 +852,20 @@ class _CategoryPopupContentState extends ConsumerState { final rank = item['rank']; final rank_heading = item['rank_heading']; final bool isPositive = item['is_rank_positive'] == true; + const aedTopics = { + 'Capital City', + 'Surface Area', + 'Population', + 'Male Population', + 'Female Population', + 'Income Level', + }; + final currencyLabel = context.translate('(AED)', '(درهم)'); + final displayTopic = + aedTopics.contains(topic) ? topic : '$topic $currencyLabel'; // final isPositive = item['is_rank_positive'] ?? false; final icon = item['icon_url']; - final bool isLast = index == items.length - 1; + final bool isLast = index == sortedItems.length - 1; return Column( children: [ @@ -737,7 +885,7 @@ class _CategoryPopupContentState extends ConsumerState { ), ), Text( - topic, + displayTopic, textAlign: TextAlign.center, style: TextStyle( fontSize: 16, @@ -756,7 +904,7 @@ class _CategoryPopupContentState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - value.toString(), + _wrapLtrTradeValue(value.toString()), style: TextStyle( fontWeight: FontWeight.w600, fontSize: 28, @@ -773,7 +921,11 @@ class _CategoryPopupContentState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text( - rank.toString(), + _wrapLtrTradeValue( + rank_heading == 'Growth Rate' + ? '${rank.toString()} %' + : rank.toString(), + ), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -894,18 +1046,9 @@ class _CategoryPopupContentState extends ConsumerState { final exportList = allTradeData['Export'] as List? ?? []; final reexportList = allTradeData['Reexport'] as List? ?? []; - importTotal = importList.fold( - 0, - (sum, item) => sum + parseTradeValue(item['value'] ?? ''), - ); - exportTotal = exportList.fold( - 0, - (sum, item) => sum + parseTradeValue(item['value'] ?? ''), - ); - reexportTotal = reexportList.fold( - 0, - (sum, item) => sum + parseTradeValue(item['value'] ?? ''), - ); + importTotal = _sumTradeEntries(importList); + exportTotal = _sumTradeEntries(exportList); + reexportTotal = _sumTradeEntries(reexportList); } else { // Fallback: use the summary tradeData list if detailed data is missing. for (var item in tradeData) { @@ -926,22 +1069,20 @@ class _CategoryPopupContentState extends ConsumerState { } } - final total = importTotal + exportTotal + reexportTotal; - final tabs = [ { 'key': 'import', - 'label': 'Total Import', + 'label': context.translate('Total Import', 'إجمالي الاستيراد'), 'total': importTotal, }, { 'key': 'export', - 'label': 'Total Export', + 'label': context.translate('Total Export', 'إجمالي الصادرات'), 'total': exportTotal, }, { 'key': 'reexport', - 'label': 'Total ReExport', + 'label': context.translate('Total ReExport', 'إجمالي إعادة التصدير'), 'total': reexportTotal, }, ]; @@ -966,6 +1107,28 @@ class _CategoryPopupContentState extends ConsumerState { tradeLabel = selectedTradeType; } + String tradeYear = ''; + if (allTradeData.isNotEmpty) { + for (final key in ['Import', 'Export', 'Reexport']) { + final entries = allTradeData[key] as List?; + if (entries == null || entries.isEmpty) continue; + + for (final item in entries) { + if (item is! Map) continue; + final year = item['year']?.toString().trim() ?? ''; + if (year.isNotEmpty) { + tradeYear = year; + break; + } + } + if (tradeYear.isNotEmpty) break; + } + } + + final bilateralTradeTitle = tradeYear.isNotEmpty + ? '${AppLocalizations.of(context)!.competitiveness_uae_bilateral_trade} ($tradeYear)' + : AppLocalizations.of(context)!.competitiveness_uae_bilateral_trade; + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -973,7 +1136,7 @@ class _CategoryPopupContentState extends ConsumerState { /// 🔹 Title Text( - AppLocalizations.of(context)!.competitiveness_uae_bilateral_trade, + bilateralTradeTitle, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, @@ -995,7 +1158,9 @@ class _CategoryPopupContentState extends ConsumerState { color: isDarkTheme ? Colors.white : Color(0xFF000000)), ), TextSpan( - text: '\$${formatTradeValue(total)}', + text: formatTradeValue( + importTotal + exportTotal + reexportTotal, + ), style: TextStyle( fontSize: 28, fontWeight: FontWeight.w700, @@ -1030,11 +1195,15 @@ class _CategoryPopupContentState extends ConsumerState { selectedTradeType = typeKey; if (typeKey == 'import') { - tradeItems = allTradeData['Import'] ?? []; + tradeItems = + _topTradeEntries(allTradeData['Import'] ?? []); } else if (typeKey == 'export') { - tradeItems = allTradeData['Export'] ?? []; + tradeItems = + _topTradeEntries(allTradeData['Export'] ?? []); } else if (typeKey == 'reexport') { - tradeItems = allTradeData['Reexport'] ?? []; + tradeItems = _topTradeEntries( + allTradeData['Reexport'] ?? [], + ); } }); }, @@ -1101,10 +1270,10 @@ class _CategoryPopupContentState extends ConsumerState { /// 🔹 Commodity Section Placeholder Text( - context.translate( - 'Top 5 $tradeLabel Commodities', - 'أفضل 5 سلع $tradeLabel', - ), + context.translate( + 'Top 5 $tradeLabel Commodities', + 'أفضل 5 سلع $tradeLabel', + ), style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, @@ -1162,7 +1331,7 @@ class _CategoryPopupContentState extends ConsumerState { width: 40, height: 40, errorBuilder: (_, __, ___) => - const SizedBox(width: 40, height: 40), + const SizedBox(width: 40, height: 40), ), ), @@ -1192,7 +1361,8 @@ class _CategoryPopupContentState extends ConsumerState { Align( alignment: Alignment.topCenter, child: Text( - '\$${formatTradeValue(parseTradeValue(item['value'] ?? ''))}', + formatTradeDisplayValue( + (item['value'] ?? '').toString()), style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, @@ -1205,7 +1375,7 @@ class _CategoryPopupContentState extends ConsumerState { ); }, ), - SizedBox(height: 15), + const SizedBox(height: 8), ], ); } diff --git a/lib/presentation/routes/drawer_routes/Drawer Items/contactUs.dart b/lib/presentation/routes/drawer_routes/Drawer Items/contactUs.dart index 3ffc6805..da95ac8f 100755 --- a/lib/presentation/routes/drawer_routes/Drawer Items/contactUs.dart +++ b/lib/presentation/routes/drawer_routes/Drawer Items/contactUs.dart @@ -174,28 +174,25 @@ class _ContactState extends ConsumerState { color: isDarkTheme ? Colors.black : Colors.grey[200], width: screenWidth, padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( + child: Column( children: [ buildContactCard(context, - title: 'DUBAI - EMIRATES TOWER - 32ND FLOOR', - titleAr: 'دبي - أبراج الإمارات - الطابق 32', + title: 'Federal Competitiveness and Statistics Centre', + titleAr: 'المركز الاتحادي للتنافسية والإحصاء', details: [ { 'icon': EnquiryAssetIconPath.building, - 'text': - 'Office: Dubai - Emirates Tower - 32nd Floor', - 'textAr': - 'المقر الرئيسي: دبي - أبراج الإمارات - الطابق 32' + 'text': 'Office: PO Box 127000, 32nd Floor, Jumeirah Emirates Towers, Dubai, United Arab Emirates', + 'textAr': 'ص.ب. 127000، الطابق 32، أبراج الإمارات، دبي، الإمارات العربية المتحدة' }, { 'icon': EnquiryAssetIconPath.phone, - 'text': 'Tel: 00971 4608 0000', - 'textAr': 'الهاتف: 0097146080000' + 'text': 'Tel: +971 4 608 0000', + 'textAr': 'الهاتف: +971 4 608 0000' }, { 'icon': EnquiryAssetIconPath.fax, - 'text': 'Fax: 00971 4327 3535', + 'text': 'Fax: +971 4 327 3535', 'textAr': 'فاكس: 0097143273535' }, { @@ -220,97 +217,97 @@ class _ContactState extends ConsumerState { }, ], isDarkTheme: isDarkTheme), - const SizedBox(height: 10), - buildContactCard( - context, - title: 'MEDIA SECTION', - titleAr: 'قسم الاعلام', - details: [ - { - 'icon': EnquiryAssetIconPath.building, - 'text': 'Office: Media Section', - 'textAr': 'المقر الرئيسي: قسم الاعلام' - }, - { - 'icon': EnquiryAssetIconPath.phone, - 'text': 'Tel: 00971 4608 0000', - 'textAr': 'الهاتف: 0097146080000' - }, - { - 'icon': EnquiryAssetIconPath.fax, - 'text': 'Fax: 00971 4327 3535', - 'textAr': 'فاكس: 0097143273535' - }, - { - 'icon': EnquiryAssetIconPath.map, - 'text': 'P.O.Box: 127000 Dubai', - 'textAr': 'ص.ب: 127000 دبي' - }, - { - 'icon': EnquiryAssetIconPath.at, - 'text': 'Email Address: GCO@fcsc.gov.ae', - 'textAr': 'البريد الإلكتروني: GCO@fcsc.gov.ae' - }, - { - 'icon': EnquiryAssetIconPath.map, - 'text': 'Latitude: 25.223926', - 'textAr': 'خط العرض: 25.223926' - }, - { - 'icon': EnquiryAssetIconPath.map, - 'text': 'Longitude: 55.350526', - 'textAr': 'خط الطول: 55.350526' - }, - ], - isDarkTheme: isDarkTheme, - ), - const SizedBox(height: 10), - buildContactCard( - context, - title: 'SGDS', - titleAr: 'أهداف التنمية المستدامة', - details: [ - { - 'icon': EnquiryAssetIconPath.building, - 'text': 'Office: SDGs', - 'textAr': 'المقر الرئيسي: أهداف التنمية المستدامة' - }, - { - 'icon': EnquiryAssetIconPath.phone, - 'text': 'Tel: 00971 4608 0000', - 'textAr': 'الهاتف: 0097146080000' - }, - { - 'icon': EnquiryAssetIconPath.fax, - 'text': 'Fax: 00971 4327 3535', - 'textAr': 'فاكس : 0097143273535' - }, - { - 'icon': EnquiryAssetIconPath.map, - 'text': 'P.O.Box: 127000 Dubai', - 'textAr': 'ص.ب: 127000 دبي' - }, - { - 'icon': EnquiryAssetIconPath.at, - 'text': 'Email Address: sdgs@fcsc.gov.ae', - 'textAr': 'البريد الإلكتروني: sdgs@fcsc.gov.ae' - }, - { - 'icon': EnquiryAssetIconPath.map, - 'text': 'Latitude: 25.223926', - 'textAr': 'خط العرض: 25.223926' - }, - { - 'icon': EnquiryAssetIconPath.map, - 'text': 'Longitude: 55.350526', - 'textAr': 'خط الطول: 55.350526' - }, - ], - isDarkTheme: isDarkTheme, - ), + // const SizedBox(height: 10), + // buildContactCard( + // context, + // title: 'MEDIA SECTION', + // titleAr: 'قسم الاعلام', + // details: [ + // { + // 'icon': EnquiryAssetIconPath.building, + // 'text': 'Office: Media Section', + // 'textAr': 'المقر الرئيسي: قسم الاعلام' + // }, + // { + // 'icon': EnquiryAssetIconPath.phone, + // 'text': 'Tel: +971 4 608 0000', + // 'textAr': 'الهاتف: +971 4 608 0000' + // }, + // { + // 'icon': EnquiryAssetIconPath.fax, + // 'text': 'Fax: +971 4 327 3535', + // 'textAr': 'فاكس: 0097143273535' + // }, + // { + // 'icon': EnquiryAssetIconPath.map, + // 'text': 'P.O.Box: 127000 Dubai', + // 'textAr': 'ص.ب: 127000 دبي' + // }, + // { + // 'icon': EnquiryAssetIconPath.at, + // 'text': 'Email Address: GCO@fcsc.gov.ae', + // 'textAr': 'البريد الإلكتروني: GCO@fcsc.gov.ae' + // }, + // { + // 'icon': EnquiryAssetIconPath.map, + // 'text': 'Latitude: 25.223926', + // 'textAr': 'خط العرض: 25.223926' + // }, + // { + // 'icon': EnquiryAssetIconPath.map, + // 'text': 'Longitude: 55.350526', + // 'textAr': 'خط الطول: 55.350526' + // }, + // ], + // isDarkTheme: isDarkTheme, + // ), + // const SizedBox(height: 10), + // buildContactCard( + // context, + // title: 'SGDS', + // titleAr: 'أهداف التنمية المستدامة', + // details: [ + // { + // 'icon': EnquiryAssetIconPath.building, + // 'text': 'Office: SDGs', + // 'textAr': 'المقر الرئيسي: أهداف التنمية المستدامة' + // }, + // { + // 'icon': EnquiryAssetIconPath.phone, + // 'text': 'Tel: +971 4 608 0000', + // 'textAr': 'الهاتف: +971 4 608 0000' + // }, + // { + // 'icon': EnquiryAssetIconPath.fax, + // 'text': 'Fax: +971 4 327 3535', + // 'textAr': 'فاكس : 0097143273535' + // }, + // { + // 'icon': EnquiryAssetIconPath.map, + // 'text': 'P.O.Box: 127000 Dubai', + // 'textAr': 'ص.ب: 127000 دبي' + // }, + // { + // 'icon': EnquiryAssetIconPath.at, + // 'text': 'Email Address: sdgs@fcsc.gov.ae', + // 'textAr': 'البريد الإلكتروني: sdgs@fcsc.gov.ae' + // }, + // { + // 'icon': EnquiryAssetIconPath.map, + // 'text': 'Latitude: 25.223926', + // 'textAr': 'خط العرض: 25.223926' + // }, + // { + // 'icon': EnquiryAssetIconPath.map, + // 'text': 'Longitude: 55.350526', + // 'textAr': 'خط الطول: 55.350526' + // }, + // ], + // isDarkTheme: isDarkTheme, + // ), ], ), - ), + ), title: Text(context.translate('Contact Us', 'اتصل بنا')), ), diff --git a/lib/presentation/routes/drawer_routes/Drawer Items/user_guide/faq_page.dart b/lib/presentation/routes/drawer_routes/Drawer Items/user_guide/faq_page.dart index 6e324779..06780d99 100755 --- a/lib/presentation/routes/drawer_routes/Drawer Items/user_guide/faq_page.dart +++ b/lib/presentation/routes/drawer_routes/Drawer Items/user_guide/faq_page.dart @@ -107,7 +107,7 @@ class _FAQPageState extends ConsumerState { return BaseScaffold( dividerColor: Colors.grey[300], showDivider: true, - showBackButton: true, + showBackButton: false, // appbarColor: Colors.white, backgroundColor: isDarkTheme ? Colors.black : Colors.white, appbarColor: isDarkTheme ? Colors.black : Colors.white, diff --git a/lib/presentation/routes/drawer_routes/Drawer Items/user_guide/userguide.dart b/lib/presentation/routes/drawer_routes/Drawer Items/user_guide/userguide.dart index 44f3761e..74b04039 100755 --- a/lib/presentation/routes/drawer_routes/Drawer Items/user_guide/userguide.dart +++ b/lib/presentation/routes/drawer_routes/Drawer Items/user_guide/userguide.dart @@ -1,238 +1,129 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:uae_stat/config/theme/theme_provider.dart'; -import 'package:uae_stat/domain/use_cases/language.dart'; -import 'package:uae_stat/l10n/app_localizations.dart'; -import 'package:uae_stat/presentation/routes/drawer_routes/custom_drawer_routes.dart'; -import 'package:iconify_design/iconify_design.dart'; - -class Userguide extends ConsumerStatefulWidget { - Userguide({super.key}); - - @override - ConsumerState createState() => _UserguideState(); -} - -class _UserguideState extends ConsumerState { - late bool isDarkTheme; - - final List> guideList = [ - { - 'routePath': 'aboutApp', - 'color': Color(0xFF90B0D5), - 'text': 'Getting Started', - 'icon': 'cbi:start-tv', - 'text-ar': 'البدء' - }, - { - 'routePath': 'features', - 'color': Color(0xFFAA8E83), - 'text': 'Key Features', - 'icon': 'pajamas:issue-type-feature', - 'text-ar': 'الميزات الرئيسية' - }, - { - 'routePath': 'faq', - 'color': Color(0xFF7296BE), - 'text': 'FAQs', - 'icon': 'mdi:faq', - 'text-ar': 'لأسئلة الشائعة' - }, - ]; - - late ThemeMode currentTheme; - @override - initState() { - super.initState(); - } - - @override - Widget buildDynamicWidget(dynamic icons, Color color) { - if (icons is IconData) { - return Icon( - icons, - size: 38, - color: color, // Apply color dynamically - ); - } else if (icons is String) { - return Text( - icons, - style: TextStyle( - fontFamily: context.translate( - 'Roboto', - 'NotoKufi', - ), - // fontFamily: 'Handel', - fontWeight: FontWeight.w700, - fontSize: 18, - letterSpacing: 1.6, - color: color, // Apply color dynamically - ), - ); - } else { - return Text( - 'Unsupported type', - style: TextStyle( - color: Colors.red, - fontFamily: context.translate( - 'Roboto', - 'NotoKufi', - ), - ), - ); - } - } - - @override - Widget build(BuildContext context) { - final currentTheme = ref.watch(themeProvider); - isDarkTheme = currentTheme == ThemeMode.dark || - (currentTheme == ThemeMode.system && - MediaQuery.of(context).platformBrightness == Brightness.dark); - - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - if (didPop) return; - context.go('/myhomepage'); - }, - child: BaseScaffold( - showDivider: true, - bottomColor: isDarkTheme ? Colors.black : Colors.white, - // dividerColor: isDarkTheme ? Color(0xFF111111) : Colors.grey[300], - dividerColor: isDarkTheme ? Colors.grey : Colors.grey[300], - appbarColor: isDarkTheme ? Colors.black: Colors.white, - title: Text(AppLocalizations.of(context)!.guide_title, - style: TextStyle( - color: isDarkTheme ? Colors.white : Color(0xFF414042))), - body: Container( - color: isDarkTheme ? Colors.black : Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - child: GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 10.0, - mainAxisSpacing: 10.0, - childAspectRatio: 1.7, - // mainAxisExtent:MediaQuery.of(context).size.height* 0.15 , - ), - itemCount: guideList.length, - itemBuilder: (context, index) { - return HoverContainer( - iconName: guideList[index]['icon'], - title: context.translate( - guideList[index]['text'], guideList[index]['text-ar']), - routePath: guideList[index]['routePath'], - ); - }, - ), - ), - ), - ); - } -} - -class HoverContainer extends ConsumerStatefulWidget { - final String iconName; - final String title; - final String routePath; - - const HoverContainer( - {Key? key, - required this.iconName, - required this.title, - required this.routePath}) - : super(key: key); - - @override - ConsumerState createState() => _HoverContainerState(); -} - -class _HoverContainerState extends ConsumerState { - bool isHovered = true; - @override - Widget build(BuildContext context) { - final double screenWidth = MediaQuery.of(context).size.width; - final double screenHeight = MediaQuery.of(context).size.height; - final currentTheme = ref.read(themeProvider); - final isDarkTheme = currentTheme == ThemeMode.dark || - (currentTheme == ThemeMode.system && - MediaQuery.of(context).platformBrightness == Brightness.dark); - - return GestureDetector( - onTap: () { - context.push('/user-guide/${widget.routePath}'); - }, - child: MouseRegion( - onEnter: (_) => setState(() => isHovered = true), - onExit: (_) => setState(() => isHovered = true), - child: Container( - padding: EdgeInsets.only(top: 4, right: 4, left: 4, bottom: 6), - width: screenWidth * 0.30, - margin: EdgeInsets.all(1), - decoration: BoxDecoration( - color: isDarkTheme ? Colors.black : Colors.white, - - borderRadius: BorderRadius.circular(10), - boxShadow: isHovered - ? [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 2, - spreadRadius: 1, - offset: Offset(0, 4), - ), - ] - : [], - // border: Border.all(color: Color(0xFF7DAFBC), width: 1.0), - border: Border.all(color: Color(0xFFB68A34), width: 1.0), - ), - alignment: Alignment.center, - // duration: Duration(milliseconds: 200), - - child: LayoutBuilder(builder: (context, constraints) { - double containerWidth = constraints.maxWidth; - double containerHeight = constraints.maxHeight; - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconifyIcon( - icon: widget.iconName, - // color: Color(0xFF7DAFBC), - color: Color(0xFFB68A34), - size: containerHeight * 0.45, - ), - // buildDynamicWidget(data['icon'], Color(0xFF7DAFBC)), - SizedBox( - width: 10, - height: containerHeight * 0.04, - ), // Space between icon and text - Container( - width: containerWidth * 0.85, - padding: EdgeInsets.symmetric(horizontal: 4, vertical: 4), - margin: EdgeInsets.only(bottom: 3), - decoration: BoxDecoration( - color: Color(0xFFB68A34), - borderRadius: BorderRadius.circular(5), - border: Border.all(color: Color(0xFFB68A34))), - child: Text( - widget.title, - style: TextStyle( - color: Colors.white, // Text color - fontWeight: FontWeight.bold, - fontFamily: context.translate( - 'Roboto', - 'NotoKufi', - ), - ), - textAlign: TextAlign.center, - ), - ), - ], - ); - })), - ), - ); - } -} +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:uae_stat/config/theme/theme_provider.dart'; +import 'package:uae_stat/domain/use_cases/language.dart'; +import 'package:uae_stat/l10n/app_localizations.dart'; +import 'package:uae_stat/presentation/routes/drawer_routes/custom_drawer_routes.dart'; + +class Userguide extends ConsumerStatefulWidget { + Userguide({super.key}); + + @override + ConsumerState createState() => _UserguideState(); +} + +class _UserguideState extends ConsumerState { + late bool isDarkTheme; + + final List> guideItems = const [ + { + 'title': 'About FCSC', + 'titleAr': 'نبذة عن المركز الاتحادي للتنافسية والإحصاء', + 'path': '/user-guide/aboutApp/aboutFCSC', + }, + { + 'title': 'Getting Started', + 'titleAr': 'البدء', + 'path': '/user-guide/aboutApp/getStarted', + }, + { + 'title': 'App Features', + 'titleAr': 'الميزات الرئيسية', + 'path': '/user-guide/features/appFeatures', + }, + { + 'title': 'Who can use the app?', + 'titleAr': 'من يمكنه استخدام التطبيق؟', + 'path': '/user-guide/features/whoUseApp', + }, + { + 'title': 'Purpose of the app', + 'titleAr': 'الغرض من التطبيق', + 'path': '/user-guide/features/purpose', + }, + { + 'title': 'How to use the app', + 'titleAr': 'كيفية استخدام التطبيق', + 'path': '/user-guide/features/howUseApp', + }, + { + 'title': 'Stay updated', + 'titleAr': 'ابق على اطلاع', + 'path': '/user-guide/features/stayUpdated', + }, + { + 'title': 'How to change my password', + 'titleAr': 'كيفية تغيير كلمة المرور الخاصة بي', + 'path': '/user-guide/features/changeMyPassword', + }, + { + 'title': 'How to edit my profile', + 'titleAr': 'كيفية تعديل ملفي الشخصي', + 'path': '/user-guide/features/editMyProfile', + }, + ]; + + late ThemeMode currentTheme; + @override + initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final currentTheme = ref.watch(themeProvider); + isDarkTheme = currentTheme == ThemeMode.dark || + (currentTheme == ThemeMode.system && + MediaQuery.of(context).platformBrightness == Brightness.dark); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (didPop) return; + context.go('/myhomepage'); + }, + child: BaseScaffold( + showDivider: true, + bottomColor: isDarkTheme ? Colors.black : Colors.white, + // dividerColor: isDarkTheme ? Color(0xFF111111) : Colors.grey[300], + dividerColor: isDarkTheme ? Colors.grey : Colors.grey[300], + appbarColor: isDarkTheme ? Colors.black: Colors.white, + title: Text(AppLocalizations.of(context)!.guide_title, + style: TextStyle( + color: isDarkTheme ? Colors.white : Color(0xFF414042))), + body: Container( + color: isDarkTheme ? Colors.black : Colors.white, + child: ListView.separated( + itemCount: guideItems.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: isDarkTheme ? Colors.grey : const Color(0xFFDEDEDE), + ), + itemBuilder: (context, index) { + final item = guideItems[index]; + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + title: Text( + context.translate(item['title']!, item['titleAr']!), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: isDarkTheme ? Colors.white : const Color(0xFF414042), + fontFamily: context.translate('Roboto', 'NotoKufi'), + ), + ), + trailing: Icon( + Icons.keyboard_arrow_right, + color: isDarkTheme ? const Color(0xFF898C81) : const Color(0xFF898C81), + ), + onTap: () => context.push(item['path']!), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/routes/drawer_routes/custom_drawer_routes.dart b/lib/presentation/routes/drawer_routes/custom_drawer_routes.dart index 720c2993..ef2fe155 100755 --- a/lib/presentation/routes/drawer_routes/custom_drawer_routes.dart +++ b/lib/presentation/routes/drawer_routes/custom_drawer_routes.dart @@ -2,12 +2,14 @@ import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:pocketbase/pocketbase.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:tutorial_coach_mark/tutorial_coach_mark.dart'; import 'package:uae_stat/config/injector.dart'; @@ -21,6 +23,7 @@ import 'package:uae_stat/infrastructure/data/p_auth_repo.dart'; import 'package:uae_stat/infrastructure/services/img_asset_paths/icons/bottom_bar_asset_icon_path.dart'; import 'package:uae_stat/infrastructure/services/pocketbase_service.dart'; import 'package:uae_stat/l10n/app_localizations.dart'; +import 'package:uae_stat/presentation/routes/auth_routes/oauth_webview.dart'; import 'package:uae_stat/presentation/components/indicators/locale_provider.dart'; import 'package:uae_stat/presentation/components/my_toggle.dart'; @@ -956,6 +959,22 @@ class _BaseScaffoldState extends ConsumerState { MediaQuery.of(context).platformBrightness == Brightness.dark); final String currentRoute1 = ModalRoute.of(context)?.settings.name ?? ''; + final hideBottomNavActiveColor = currentRoute == '/feedback' || + currentRoute == '/notification' || + currentRoute == '/notification_details' || + currentRoute == '/user-guide' || + currentRoute.startsWith('/user-guide/') || + currentRoute == '/aboutApp' || + currentRoute == '/faq' || + currentRoute == '/editProfile' || + currentRoute == '/bookmark' || + currentRoute == '/manageuser' || + currentRoute == '/contact' || + currentRoute == '/chartScreen/:dataSets'; + final bottomNavActiveColor = hideBottomNavActiveColor + ? const Color(0xFF989898) + : const Color(0xFFAA8E83); + final bottomNavInactiveColor = const Color(0xFF989898); return Scaffold( backgroundColor: widget.backgroundColor, @@ -1416,6 +1435,21 @@ class _BaseScaffoldState extends ConsumerState { ), onTap: () => _navigateTo(context, '/user-guide'), ), + ListTile( + leading: Icon(Icons.quiz_outlined, + color: isDarkTheme ? Color(0xFFFFFFFF) : null), + title: Text( + context.translate('FAQs', 'الأسئلة الشائعة'), + style: TextStyle( + color: isDarkTheme ? Color(0xFFAAAAAA) : null, + fontFamily: context.translate( + 'Roboto', + 'NotoKufi', + ), + ), + ), + onTap: () => _navigateTo(context, '/faq'), + ), ListTile( leading: Icon(Icons.mail_outlined, color: isDarkTheme ? Color(0xFFFFFFFF) : null), @@ -1587,8 +1621,8 @@ class _BaseScaffoldState extends ConsumerState { BottomBarAssetIconPath.home, fit: BoxFit.contain, color: _getSelectedIndex(currentRoute) == 0 - ? Color(0xFFAA8E83) - : Color(0xFF989898), + ? bottomNavActiveColor + : bottomNavInactiveColor, ), ), ), @@ -1606,8 +1640,8 @@ class _BaseScaffoldState extends ConsumerState { BottomBarAssetIconPath.uaeMap, fit: BoxFit.contain, color: _getSelectedIndex(currentRoute) == 1 - ? Color(0xFFAA8E83) - : Color(0xFF989898), + ? bottomNavActiveColor + : bottomNavInactiveColor, ), ), ), @@ -1624,8 +1658,8 @@ class _BaseScaffoldState extends ConsumerState { BottomBarAssetIconPath.ranking, fit: BoxFit.contain, color: _getSelectedIndex(currentRoute) == 2 - ? Color(0xFFAA8E83) - : Color(0xFF989898), + ? bottomNavActiveColor + : bottomNavInactiveColor, ), ), ), @@ -1642,8 +1676,8 @@ class _BaseScaffoldState extends ConsumerState { BottomBarAssetIconPath.globe, fit: BoxFit.contain, color: _getSelectedIndex(currentRoute) == 3 - ? Color(0xFFAA8E83) - : Color(0xFF989898), + ? bottomNavActiveColor + : bottomNavInactiveColor, ), ), ), @@ -1652,8 +1686,8 @@ class _BaseScaffoldState extends ConsumerState { ), ], key: bottomNavKey, - selectedItemColor: Color(0xFFAA8E83), - unselectedItemColor: Color(0xFF989898), + selectedItemColor: bottomNavActiveColor, + unselectedItemColor: bottomNavInactiveColor, selectedLabelStyle: TextStyle( fontFamily: context.translate( 'Roboto', @@ -1812,11 +1846,61 @@ class _BaseScaffoldState extends ConsumerState { final PAuthRepo _authRepo = PAuthRepo(); +Future _logoutFromUaePassIfNeeded(BuildContext context) async { + final model = PocketBaseService.authStore.model; + if (model is! RecordModel) return; + + final data = model.data; + final uuid = (data['uuid'] ?? '').toString().trim(); + final isOAuthLogin = data['is_oauth_login']; + final oauthLikeValues = {'1', '-1', 'true'}; + final oauthFlag = oauthLikeValues.contains( + isOAuthLogin?.toString().toLowerCase(), + ); + + // Best-effort UAE PASS detection for current session. + final isUaePassUser = uuid.isNotEmpty && oauthFlag; + if (!isUaePassUser) return; + + // UAE PASS updated logout contract: pass extra details via base64 `state` + // inside the redirect_uri query. + final stateQuery = 'source=uae_stat&flow=logout&ts=${DateTime.now().millisecondsSinceEpoch}'; + final encodedState = base64UrlEncode(utf8.encode(stateQuery)); + final redirectUri = Uri.parse( + 'https://pb.venbait.in/api/oauth2-redirect', + ).replace(queryParameters: {'state': encodedState}); + final logoutUri = Uri.https( + 'stg-id.uaepass.ae', + '/idshub/logout', + {'redirect_uri': redirectUri.toString()}, + ); + try { + if (kIsWeb) { + await launchUrl(logoutUri, mode: LaunchMode.externalApplication); + return; + } + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OAuthWebView( + authUrl: logoutUri.toString(), + useUaePassDeepLink: false, + ), + ), + ); + } catch (_) { + // Keep local logout flow even if UAE PASS logout launch fails. + } +} + Future logout(BuildContext context) async { final prefs = await SharedPreferences.getInstance(); + // await _logoutFromUaePassIfNeeded(context); await _authRepo.logout(); - prefs.clear(); - context.go('/login'); // Redirect to login after logout + await prefs.clear(); + if (context.mounted) { + context.go('/login'); // Redirect to login after logout + } } void _showLogoutConfirmationDialog(BuildContext context, isDarkTheme) { diff --git a/pubspec.yaml b/pubspec.yaml index f7aee7be..0b2062cf 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,8 +1,8 @@ name: uae_stat description: "View statistics about the UAE from the Federal Center for Statistics and Competitiveness." publish_to: "none" -#version: 1.2.18+26 -version: 1.2.16+24 +#version: 1.2.26+34 +version: 1.2.24+32 #version: 1.0.16+17 #version: 1.0.6+6 @@ -173,6 +173,7 @@ flutter: - assets/icons/uae_numbers/ShareDark.png - assets/icons/uae_numbers/ShareLight.png - assets/icons/misc/successMsg.png + - assets/uae_pass_button/ fonts: - family: Segoe