new design
@ -20,12 +20,12 @@ if (project.hasProperty('google-services.json')) {
|
|||||||
|
|
||||||
def flutterVersionCode = localProperties.getProperty("flutter.versionCode")
|
def flutterVersionCode = localProperties.getProperty("flutter.versionCode")
|
||||||
if (flutterVersionCode == null) {
|
if (flutterVersionCode == null) {
|
||||||
flutterVersionCode = "26"
|
flutterVersionCode = "34"
|
||||||
}
|
}
|
||||||
|
|
||||||
def flutterVersionName = localProperties.getProperty("flutter.versionName")
|
def flutterVersionName = localProperties.getProperty("flutter.versionName")
|
||||||
if (flutterVersionName == null) {
|
if (flutterVersionName == null) {
|
||||||
flutterVersionName = "1.2.18"
|
flutterVersionName = "1.2.26"
|
||||||
}
|
}
|
||||||
|
|
||||||
def keystorePropertiesFile = rootProject.file("key.properties")
|
def keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
|||||||
@ -52,6 +52,7 @@
|
|||||||
android:host="resume_authn" />
|
android:host="resume_authn" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
|
||||||
<!-- AppsFlyer OneLink fallback: fcscappstates://callback?deep_link_value=... (matches dashboard) -->
|
<!-- AppsFlyer OneLink fallback: fcscappstates://callback?deep_link_value=... (matches dashboard) -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
@ -114,6 +115,22 @@
|
|||||||
<package android:name="ae.uaepass.mainapp" />
|
<package android:name="ae.uaepass.mainapp" />
|
||||||
<package android:name="ae.uaepass.mainapp.qa" />
|
<package android:name="ae.uaepass.mainapp.qa" />
|
||||||
<package android:name="ae.uaepass.mainapp.stg" />
|
<package android:name="ae.uaepass.mainapp.stg" />
|
||||||
|
<!-- Package visibility: URL Launcher / canLaunchUrl for UAE PASS schemes (live + stg + qa). -->
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="uaepass" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="uaepassstg" />
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="uaepassqa" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
|||||||
BIN
assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Active_dark.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
assets/uae_pass_button/AR_UAEPASS_Sign_in_Btn_Active_light.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/uae_pass_button/AR_UAEPASS_Sign_up_Btn_Outline_Active.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
BIN
assets/uae_pass_button/UAEPASS_Sign_up_Btn_Outline_Active.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
@ -513,7 +513,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.16;
|
MARKETING_VERSION = 1.2.24;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@ -707,7 +707,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.16;
|
MARKETING_VERSION = 1.2.24;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@ -737,7 +737,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.16;
|
MARKETING_VERSION = 1.2.24;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@ -10,4 +10,20 @@ import UIKit
|
|||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called by iOS when UAE PASS app redirects back to your app scheme.
|
||||||
|
// aegovfcscstats://resume_authn?url=<encoded-original-successURL>
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -35,7 +35,7 @@
|
|||||||
<string>ae.gov.fcsc.frontend.ios</string>
|
<string>ae.gov.fcsc.frontend.ios</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>ae.gov.fcsc.frontend.ios</string>
|
<string>aegovfcscfrontendios</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<!-- UAE PASS resume_authn callback: ae.gov.fcsc.stats://resume_authn?url=... -->
|
<!-- UAE PASS resume_authn callback: ae.gov.fcsc.stats://resume_authn?url=... -->
|
||||||
@ -44,7 +44,7 @@
|
|||||||
<string>ae.gov.fcsc.stats</string>
|
<string>ae.gov.fcsc.stats</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>ae.gov.fcsc.stats</string>
|
<string>aegovfcscstats</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleRole</key>
|
<key>CFBundleRole</key>
|
||||||
<string>Editor</string>
|
<string>Editor</string>
|
||||||
|
|||||||
@ -3,3 +3,9 @@
|
|||||||
// const String apiUrl = 'https://pbdev.venbait.in/';
|
// const String apiUrl = 'https://pbdev.venbait.in/';
|
||||||
const String apiUrl = 'https://pb.venbait.in/';
|
const String apiUrl = 'https://pb.venbait.in/';
|
||||||
// const String apiUrl = 'https://pocket.fcsc.gov.ae/';
|
// 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;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:uae_stat/config/sessionCheckScreen.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/auth_verification/terms&conditions.dart';
|
||||||
import 'package:uae_stat/presentation/Screens/charts/screens/chart_screen.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/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/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/Detailedcompetitiveness.dart';
|
||||||
import 'package:uae_stat/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/competitiveness.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/auth_verification/create_new_pw.dart';
|
||||||
import '../presentation/Screens/profilepage.dart';
|
import '../presentation/Screens/profilepage.dart';
|
||||||
import '../presentation/routes/auth_routes/login_route.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 '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/edit_profile.dart';
|
||||||
import '../presentation/routes/drawer_routes/Drawer Items/feedback.dart';
|
import '../presentation/routes/drawer_routes/Drawer Items/feedback.dart';
|
||||||
@ -114,13 +113,6 @@ final GoRouter router = GoRouter(
|
|||||||
// path: '/register',
|
// path: '/register',
|
||||||
// builder: (context, state) => RegisterScreen(),
|
// builder: (context, state) => RegisterScreen(),
|
||||||
// ),
|
// ),
|
||||||
GoRoute(
|
|
||||||
path: '/resume_authn',
|
|
||||||
builder: (context, state) {
|
|
||||||
final urlToLoad = state.uri.queryParameters['url'];
|
|
||||||
return UaePassResumeAuthScreen(urlToLoad: urlToLoad);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/fcscprofilelinking',
|
path: '/fcscprofilelinking',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@ -140,10 +132,7 @@ final GoRouter router = GoRouter(
|
|||||||
path: '/myhomepage',
|
path: '/myhomepage',
|
||||||
builder: (context, state) => MyHomePage(),
|
builder: (context, state) => MyHomePage(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: '/checkingUAEPass',
|
|
||||||
builder: (context, state) => checkingUAEPass(),
|
|
||||||
),
|
|
||||||
|
|
||||||
// GoRoute(
|
// GoRoute(
|
||||||
// path: '/DemoHome/:dataSets',
|
// path: '/DemoHome/:dataSets',
|
||||||
@ -417,6 +406,10 @@ final GoRouter router = GoRouter(
|
|||||||
path: '/contact',
|
path: '/contact',
|
||||||
builder: (context, state) => Contact(),
|
builder: (context, state) => Contact(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/faq',
|
||||||
|
builder: (context, state) => FAQPage(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
redirect: (context, state) async {
|
redirect: (context, state) async {
|
||||||
// If UAE PASS redirected here with ?code=
|
// If UAE PASS redirected here with ?code=
|
||||||
|
|||||||
63
lib/config/uae_pass_oauth_errors.dart
Normal file
@ -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 = <String>{
|
||||||
|
'invalid_request',
|
||||||
|
'login_required',
|
||||||
|
'access_denied',
|
||||||
|
'cancelledonapp',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Digital signature cancellations (subset per UAE PASS table).
|
||||||
|
const _signingCancelledCodes = <String>{
|
||||||
|
'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<String, dynamic> response,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
final blob = jsonEncode(response).toLowerCase();
|
||||||
|
final ordered = <String>{
|
||||||
|
..._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;
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@ class UaePassOAuthState {
|
|||||||
String? codeVerifier;
|
String? codeVerifier;
|
||||||
String? redirectUrl;
|
String? redirectUrl;
|
||||||
String? providerName;
|
String? providerName;
|
||||||
|
String? oauthCode;
|
||||||
|
String? oauthState;
|
||||||
|
|
||||||
/// BCP47 tag passed as OIDC `ui_locales` (e.g. `ar`, `en`) so UAE PASS login
|
/// 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
|
/// UI matches the language the user chose in the app. Read after auth to set
|
||||||
@ -39,6 +41,8 @@ class UaePassOAuthState {
|
|||||||
codeVerifier = null;
|
codeVerifier = null;
|
||||||
redirectUrl = null;
|
redirectUrl = null;
|
||||||
providerName = null;
|
providerName = null;
|
||||||
|
oauthCode = null;
|
||||||
|
oauthState = null;
|
||||||
pendingUiLocales = null;
|
pendingUiLocales = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,27 @@
|
|||||||
abstract class MiscIconAssetPath {
|
abstract class MiscIconAssetPath {
|
||||||
static const _basePath = 'assets/icons/misc';
|
static const _basePath = 'assets/icons/misc';
|
||||||
|
static const _uaePassButtonBasePath = 'assets/uae_pass_button';
|
||||||
static const rightChevronInSemiCircle =
|
static const rightChevronInSemiCircle =
|
||||||
'$_basePath/right_chevron_in_semicircle.png';
|
'$_basePath/right_chevron_in_semicircle.png';
|
||||||
static const signInWithGoogle = '$_basePath/signInWithGoogle.png';
|
static const signInWithGoogle = '$_basePath/signInWithGoogle.png';
|
||||||
static const signInWithApple = '$_basePath/signInWithApple.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 lock = '$_basePath/lock.png';
|
||||||
static const lockDark = '$_basePath/lockDark.png';
|
static const lockDark = '$_basePath/lockDark.png';
|
||||||
static const lockLight = '$_basePath/lockLight.png';
|
static const lockLight = '$_basePath/lockLight.png';
|
||||||
|
|||||||
@ -8,6 +8,10 @@ abstract class PocketBaseService {
|
|||||||
// static const _host = 'https://pb.venbait.in';
|
// static const _host = 'https://pb.venbait.in';
|
||||||
// static const _host = 'http://127.0.0.1:8090';
|
// static const _host = 'http://127.0.0.1:8090';
|
||||||
static final _pb = PocketBase(_host);
|
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 users = _pb.collection('users');
|
||||||
static final authStore = _pb.authStore;
|
static final authStore = _pb.authStore;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -518,6 +518,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Rank'**
|
/// **'Rank'**
|
||||||
String get competitiveness_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.
|
/// No description provided for @competitiveness_previous_edition.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@ -551,7 +563,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @competitiveness_uae_bilateral_trade.
|
/// No description provided for @competitiveness_uae_bilateral_trade.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'UAE Bilateral Trade (2022)'**
|
/// **'UAE Bilateral Trade'**
|
||||||
String get competitiveness_uae_bilateral_trade;
|
String get competitiveness_uae_bilateral_trade;
|
||||||
|
|
||||||
/// No description provided for @competitiveness_total.
|
/// No description provided for @competitiveness_total.
|
||||||
|
|||||||
@ -223,6 +223,12 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get competitiveness_rank => 'رتبة';
|
String get competitiveness_rank => 'رتبة';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get competitiveness_score_in => 'سجل في';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get competitiveness_score => 'نتيجة';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get competitiveness_previous_edition => 'مقابل الإصدار السابق';
|
String get competitiveness_previous_edition => 'مقابل الإصدار السابق';
|
||||||
|
|
||||||
@ -242,7 +248,7 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get competitiveness_uae_bilateral_trade =>
|
String get competitiveness_uae_bilateral_trade =>
|
||||||
'التجارة الثنائية لدولة الإمارات العربية المتحدة (2022)';
|
'التجارة الثنائية لدولة الإمارات العربية المتحدة';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get competitiveness_total => 'المجموع ';
|
String get competitiveness_total => 'المجموع ';
|
||||||
|
|||||||
@ -223,6 +223,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get competitiveness_rank => 'Rank';
|
String get competitiveness_rank => 'Rank';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get competitiveness_score_in => 'Score in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get competitiveness_score => 'Score';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get competitiveness_previous_edition => 'vs. Previous Edition';
|
String get competitiveness_previous_edition => 'vs. Previous Edition';
|
||||||
|
|
||||||
@ -240,8 +246,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get competitiveness_multi_countries => 'MULTIPLE COUNTRIES';
|
String get competitiveness_multi_countries => 'MULTIPLE COUNTRIES';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get competitiveness_uae_bilateral_trade =>
|
String get competitiveness_uae_bilateral_trade => 'UAE Bilateral Trade';
|
||||||
'UAE Bilateral Trade (2022)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get competitiveness_total => 'Total ';
|
String get competitiveness_total => 'Total ';
|
||||||
|
|||||||
@ -78,12 +78,14 @@
|
|||||||
"competitiveness_heading": "التقارير التنافسية",
|
"competitiveness_heading": "التقارير التنافسية",
|
||||||
"competitiveness_rank_in": "الترتيب في",
|
"competitiveness_rank_in": "الترتيب في",
|
||||||
"competitiveness_rank": "رتبة",
|
"competitiveness_rank": "رتبة",
|
||||||
|
"competitiveness_score_in": "سجل في",
|
||||||
|
"competitiveness_score": "نتيجة",
|
||||||
"competitiveness_previous_edition": "مقابل الإصدار السابق",
|
"competitiveness_previous_edition": "مقابل الإصدار السابق",
|
||||||
"competitiveness_first_globally": "أولا عالميا",
|
"competitiveness_first_globally": "أولا عالميا",
|
||||||
"competitiveness_first_in_GCC": "الأول في دول مجلس التعاون الخليجي",
|
"competitiveness_first_in_GCC": "الأول في دول مجلس التعاون الخليجي",
|
||||||
"competitiveness_first_in_arab_countries": "الأول في دول مجلس التعاون الخليجي",
|
"competitiveness_first_in_arab_countries": "الأول في دول مجلس التعاون الخليجي",
|
||||||
"competitiveness_multi_countries": "دول متعددة",
|
"competitiveness_multi_countries": "دول متعددة",
|
||||||
"competitiveness_uae_bilateral_trade":"التجارة الثنائية لدولة الإمارات العربية المتحدة (2022)",
|
"competitiveness_uae_bilateral_trade":"التجارة الثنائية لدولة الإمارات العربية المتحدة",
|
||||||
"competitiveness_total": "المجموع ",
|
"competitiveness_total": "المجموع ",
|
||||||
"country_profile": "ملف تعريف الدولة",
|
"country_profile": "ملف تعريف الدولة",
|
||||||
|
|
||||||
|
|||||||
@ -76,12 +76,14 @@
|
|||||||
"competitiveness_heading": "Competitive Reports",
|
"competitiveness_heading": "Competitive Reports",
|
||||||
"competitiveness_rank_in": "Rank in",
|
"competitiveness_rank_in": "Rank in",
|
||||||
"competitiveness_rank": "Rank",
|
"competitiveness_rank": "Rank",
|
||||||
|
"competitiveness_score_in": "Score in",
|
||||||
|
"competitiveness_score": "Score",
|
||||||
"competitiveness_previous_edition": "vs. Previous Edition",
|
"competitiveness_previous_edition": "vs. Previous Edition",
|
||||||
"competitiveness_first_globally": "First Globally",
|
"competitiveness_first_globally": "First Globally",
|
||||||
"competitiveness_first_in_GCC": "First in GCC",
|
"competitiveness_first_in_GCC": "First in GCC",
|
||||||
"competitiveness_first_in_arab_countries": "First in Arab Countries",
|
"competitiveness_first_in_arab_countries": "First in Arab Countries",
|
||||||
"competitiveness_multi_countries": "MULTIPLE COUNTRIES",
|
"competitiveness_multi_countries": "MULTIPLE COUNTRIES",
|
||||||
"competitiveness_uae_bilateral_trade": "UAE Bilateral Trade (2022)",
|
"competitiveness_uae_bilateral_trade": "UAE Bilateral Trade",
|
||||||
"competitiveness_total": "Total ",
|
"competitiveness_total": "Total ",
|
||||||
"country_profile": "Country Profile",
|
"country_profile": "Country Profile",
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:pocketbase/pocketbase.dart';
|
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/indicators/locale_provider.dart';
|
||||||
import 'package:uae_stat/presentation/components/my_toggle.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 '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 {
|
class RegisterScreen extends ConsumerStatefulWidget {
|
||||||
final String? fromPage;
|
final String? fromPage;
|
||||||
@ -316,7 +318,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
'role': 'user',
|
'role': 'user',
|
||||||
// 'status': 'Approved',
|
// 'status': 'Approved',
|
||||||
// 'user_mail_verify': true,
|
// 'user_mail_verify': true,
|
||||||
'emiratesid': uuid,
|
'uuid': uuid,
|
||||||
'is_oauth_login': 1,
|
'is_oauth_login': 1,
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
@ -538,7 +540,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
final existing = await pb.collection('users').getList(
|
final existing = await pb.collection('users').getList(
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 1,
|
perPage: 1,
|
||||||
filter: 'emiratesid = "$uuid"',
|
filter: 'uuid = "$uuid"',
|
||||||
);
|
);
|
||||||
if (existing.items.isEmpty) {
|
if (existing.items.isEmpty) {
|
||||||
// First, persist a valid session so protected routes (like /profile)
|
// First, persist a valid session so protected routes (like /profile)
|
||||||
@ -845,6 +847,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
final registerLocale = ref.watch(localeProvider);
|
final registerLocale = ref.watch(localeProvider);
|
||||||
cameFromLinking = widget.fromPage == 'fcscprofilelinking';
|
cameFromLinking = widget.fromPage == 'fcscprofilelinking';
|
||||||
|
|
||||||
|
if (cameFromLinking){
|
||||||
|
setState(() {
|
||||||
|
_emailController.text = widget.userInfo?['email']?.toString().trim() ?? '';
|
||||||
|
_usernameController.text = widget.userInfo?['firstnameEN']!.toString().trim() ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ref.listen<AsyncValue<bool>>(connectivityProvider, (previous, hasInternet) {
|
ref.listen<AsyncValue<bool>>(connectivityProvider, (previous, hasInternet) {
|
||||||
if (hasInternet.value == false) {
|
if (hasInternet.value == false) {
|
||||||
context.push('/internetcheck');
|
context.push('/internetcheck');
|
||||||
@ -863,6 +874,32 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
height: 40,
|
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) {
|
Widget buildIconContainer(IconData icon, Color iconColor) {
|
||||||
return Container(
|
return Container(
|
||||||
width: 60,
|
width: 60,
|
||||||
@ -1978,7 +2015,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 10),
|
SizedBox(height: 10),
|
||||||
|
if (!cameFromLinking) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
top: 5.0,
|
top: 5.0,
|
||||||
@ -2032,57 +2069,10 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
top: 5.0,
|
top: 5.0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 30,
|
left: 30,
|
||||||
right: 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:
|
child: signUpWithUAEPassBtn,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
|
|
||||||
if (!cameFromLinking) ...[
|
|
||||||
SizedBox(height: 20),
|
SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!
|
AppLocalizations.of(context)!
|
||||||
|
|||||||
@ -177,6 +177,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_usernameController.text = userDetailsResponse.data['uname'] ?? '';
|
_usernameController.text = userDetailsResponse.data['uname'] ?? '';
|
||||||
_emailController.text = userDetailsResponse.data['email'] ?? '';
|
_emailController.text = userDetailsResponse.data['email'] ?? '';
|
||||||
|
_fullNameController.text = userDetailsResponse.data['full_name'] ?? '';
|
||||||
role = userDetailsResponse.data['role'] ?? '';
|
role = userDetailsResponse.data['role'] ?? '';
|
||||||
isOAuthLogin = userDetailsResponse.data['is_oauth_login'] ?? '';
|
isOAuthLogin = userDetailsResponse.data['is_oauth_login'] ?? '';
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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<checkingUAEPass> createState() => _checkingUAEPassState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _checkingUAEPassState extends State<checkingUAEPass> {
|
|
||||||
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<void> 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(() {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
586
lib/presentation/routes/auth_routes/oauth_webview.dart
Normal file
@ -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<void> Function(String code, String? state)? onPocketBaseRedirect;
|
||||||
|
|
||||||
|
/// IdP cancelled or error: `/api/oauth2-redirect?error=access_denied&...` (no `code`).
|
||||||
|
final Future<void> 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<OAuthWebView> createState() => _OAuthWebViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OAuthWebViewState extends State<OAuthWebView>
|
||||||
|
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<void> _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<void> _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<void> _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<void> _showInitialAppLoader() async {
|
||||||
|
if (!mounted || _isAppLoaderVisible || _didDismissInitialAppLoader) return;
|
||||||
|
final ctxWaiter = Completer<BuildContext>();
|
||||||
|
_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<void> _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<void> _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<void> 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<String, String>.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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
216
lib/presentation/routes/auth_routes/simple_oAuth.dart
Normal file
@ -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<OAuthWebViewSimple> createState() => _OAuthWebViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OAuthWebViewState extends State<OAuthWebViewSimple>
|
||||||
|
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<void> _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<String, String>.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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<UaePassResumeAuthScreen> createState() =>
|
|
||||||
_UaePassResumeAuthScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UaePassResumeAuthScreenState
|
|
||||||
extends ConsumerState<UaePassResumeAuthScreen> {
|
|
||||||
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<void> _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<void> _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<void> _updateOAuthUserDetails(
|
|
||||||
String userId, Map<String, dynamic> metaDetails, String token) async {
|
|
||||||
try {
|
|
||||||
final userDetails = metaDetails['rawUser'] as Map<String, dynamic>?;
|
|
||||||
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<void> _getDeviceToken(String userId, String authToken) async {
|
|
||||||
// Device token update can be done from login flow if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2872
lib/presentation/routes/auth_routes/yes_profile_linking_login.dart
Normal file
@ -34,8 +34,8 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
|||||||
late UserService _userService;
|
late UserService _userService;
|
||||||
late ThemeMode currentTheme;
|
late ThemeMode currentTheme;
|
||||||
List<dynamic> reports = [];
|
List<dynamic> reports = [];
|
||||||
int currentRank = 0;
|
num currentRank = 0;
|
||||||
int previousRank = 0;
|
num previousRank = 0;
|
||||||
bool isPositive = false;
|
bool isPositive = false;
|
||||||
Map<String, dynamic> selectedReport = {};
|
Map<String, dynamic> selectedReport = {};
|
||||||
|
|
||||||
@ -43,6 +43,11 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
|||||||
List gccList = [];
|
List gccList = [];
|
||||||
List arabList = [];
|
List arabList = [];
|
||||||
|
|
||||||
|
String _formatRankValue(num value) {
|
||||||
|
if (value == 0) return '-';
|
||||||
|
return value % 1 == 0 ? value.toInt().toString() : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -295,7 +300,7 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
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(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: isDarkTheme
|
color: isDarkTheme
|
||||||
@ -308,7 +313,7 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
currentRank.toString(),
|
_formatRankValue(currentRank),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 36,
|
fontSize: 36,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
@ -317,7 +322,9 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
|||||||
: Color(0xFF000000)),
|
: Color(0xFF000000)),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
if (currentRank != previousRank)
|
if (currentRank > 0 &&
|
||||||
|
previousRank > 0 &&
|
||||||
|
currentRank != previousRank)
|
||||||
Icon(
|
Icon(
|
||||||
isPositive
|
isPositive
|
||||||
? Icons.arrow_upward
|
? Icons.arrow_upward
|
||||||
@ -348,7 +355,7 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
previousRank.toString(),
|
_formatRankValue(previousRank),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 36,
|
fontSize: 36,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
|
|||||||
@ -240,10 +240,19 @@ class _CompetitivenessState extends ConsumerState<Competitiveness> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
/// Rank Section
|
/// Rank Section
|
||||||
Column(
|
SizedBox(
|
||||||
|
width: 68,
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.competitiveness_rank,
|
report['is_score'] == false
|
||||||
|
? AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.competitiveness_rank
|
||||||
|
: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.competitiveness_score,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
@ -255,6 +264,7 @@ class _CompetitivenessState extends ConsumerState<Competitiveness> {
|
|||||||
Text(
|
Text(
|
||||||
report['current_year_rank']
|
report['current_year_rank']
|
||||||
.toString(),
|
.toString(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
@ -265,6 +275,7 @@ class _CompetitivenessState extends ConsumerState<Competitiveness> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import 'package:uae_stat/domain/use_cases/language.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class DetailedCountryProfilePage extends ConsumerStatefulWidget {
|
class DetailedCountryProfilePage extends ConsumerStatefulWidget {
|
||||||
final String countryName;
|
final String countryName;
|
||||||
@ -212,7 +213,10 @@ class _DetailedCountryProfilePageState
|
|||||||
),
|
),
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@ -249,14 +253,16 @@ class _DetailedCountryProfilePageState
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
child: CircularProgressIndicator(strokeWidth: 1),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Image.network(
|
: Image.network(
|
||||||
flagUrl,
|
flagUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => const Center(
|
errorBuilder: (_, __, ___) =>
|
||||||
|
const Center(
|
||||||
child: Icon(Icons.flag, size: 18),
|
child: Icon(Icons.flag, size: 18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -265,7 +271,9 @@ class _DetailedCountryProfilePageState
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
(countryData?['country_name'] ?? '').toString().toUpperCase(),
|
(countryData?['country_name'] ?? '')
|
||||||
|
.toString()
|
||||||
|
.toUpperCase(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@ -320,7 +328,7 @@ class _DetailedCountryProfilePageState
|
|||||||
children: availableCategories.map((key) {
|
children: availableCategories.map((key) {
|
||||||
final isBilateral = key == 'bilateral_trade';
|
final isBilateral = key == 'bilateral_trade';
|
||||||
|
|
||||||
return SizedBox(
|
final categoryButton = SizedBox(
|
||||||
width: isBilateral
|
width: isBilateral
|
||||||
? double.infinity
|
? double.infinity
|
||||||
: (MediaQuery.of(context).size.width - 48) /
|
: (MediaQuery.of(context).size.width - 48) /
|
||||||
@ -338,14 +346,54 @@ class _DetailedCountryProfilePageState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return categoryButton;
|
||||||
}).toList(),
|
}).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),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -476,7 +524,8 @@ void showCategoryPopup(BuildContext context, String category,
|
|||||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20),
|
insetPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
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(
|
return ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
@ -497,7 +546,8 @@ void showCategoryPopup(BuildContext context, String category,
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView( // ✅ SCROLL ENABLED
|
child: SingleChildScrollView(
|
||||||
|
// ✅ SCROLL ENABLED
|
||||||
child: CategoryPopupContent(
|
child: CategoryPopupContent(
|
||||||
category: category,
|
category: category,
|
||||||
statistics: statistics,
|
statistics: statistics,
|
||||||
@ -587,6 +637,9 @@ class CategoryPopupContent extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||||
|
static final NumberFormat _tradeNumberFormat = NumberFormat('#,##0.00');
|
||||||
|
|
||||||
|
String _wrapLtrTradeValue(String value) => '\u2066$value\u2069';
|
||||||
String selectedTradeType = 'import';
|
String selectedTradeType = 'import';
|
||||||
bool isLoadingTrade = false;
|
bool isLoadingTrade = false;
|
||||||
List<dynamic> tradeItems = [];
|
List<dynamic> tradeItems = [];
|
||||||
@ -602,7 +655,6 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
'إجمالي إعادة التصدير': 'reexport',
|
'إجمالي إعادة التصدير': 'reexport',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -648,7 +700,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
allTradeData = decoded;
|
allTradeData = decoded;
|
||||||
|
|
||||||
tradeItems = decoded['Import'] ?? [];
|
tradeItems = _topTradeEntries(decoded[_tradeApiKey(type)] ?? []);
|
||||||
|
|
||||||
isLoadingTrade = false;
|
isLoadingTrade = false;
|
||||||
});
|
});
|
||||||
@ -659,26 +711,105 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse API values like `AED 1.50 B` or `AED 867.50 M` into **millions**.
|
String _tradeApiKey(String typeKey) {
|
||||||
|
switch (typeKey) {
|
||||||
|
case 'export':
|
||||||
|
return 'Export';
|
||||||
|
case 'reexport':
|
||||||
|
return 'Reexport';
|
||||||
|
case 'import':
|
||||||
|
default:
|
||||||
|
return 'Import';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double _sumTradeEntries(List items) {
|
||||||
|
return items.fold<double>(
|
||||||
|
0,
|
||||||
|
(sum, item) => sum + parseTradeValue((item['value'] ?? '').toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<dynamic> _topTradeEntries(List items, {int limit = 5}) {
|
||||||
|
final sortedItems = List<dynamic>.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) {
|
double parseTradeValue(String value) {
|
||||||
final upper = value.toUpperCase();
|
final match = RegExp(
|
||||||
double multiplier = 1.0; // default: already in millions
|
r'([0-9]+(?:\.[0-9]+)?)\s*([KMBT])?',
|
||||||
|
caseSensitive: false,
|
||||||
|
).firstMatch(value.replaceAll(',', ''));
|
||||||
|
if (match == null) return 0;
|
||||||
|
|
||||||
if (upper.contains(' B')) {
|
final number = double.tryParse(match.group(1) ?? '') ?? 0;
|
||||||
// 1.5 B = 1500 M
|
final suffix = (match.group(2) ?? '').toUpperCase();
|
||||||
multiplier = 1000.0;
|
|
||||||
} else if (upper.contains(' M')) {
|
switch (suffix) {
|
||||||
multiplier = 1.0;
|
case 'K':
|
||||||
|
return number * 1000;
|
||||||
|
case 'M':
|
||||||
|
return number * 1000000;
|
||||||
|
case 'B':
|
||||||
|
return number * 1000000000;
|
||||||
|
case 'T':
|
||||||
|
return number * 1000000000000;
|
||||||
|
default:
|
||||||
|
return number;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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".
|
|
||||||
|
/// Format a full numeric value into a readable K / M / B / T label.
|
||||||
String formatTradeValue(double value) {
|
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
|
@override
|
||||||
@ -697,16 +828,22 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
|
|
||||||
final List items;
|
final List items;
|
||||||
if (widget.category == 'bilateral_trade') {
|
if (widget.category == 'bilateral_trade') {
|
||||||
return _buildBilateralTradeUI(
|
return _buildBilateralTradeUI(widget.statistics![widget.category] as List);
|
||||||
widget.statistics![widget.category] as List);
|
|
||||||
} else {
|
} else {
|
||||||
items = widget.statistics![widget.category] as List;
|
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(
|
return SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: items.asMap().entries.map((entry) {
|
children: sortedItems.asMap().entries.map((entry) {
|
||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
final item = entry.value;
|
final item = entry.value;
|
||||||
|
|
||||||
@ -715,9 +852,20 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
final rank = item['rank'];
|
final rank = item['rank'];
|
||||||
final rank_heading = item['rank_heading'];
|
final rank_heading = item['rank_heading'];
|
||||||
final bool isPositive = item['is_rank_positive'] == true;
|
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 isPositive = item['is_rank_positive'] ?? false;
|
||||||
final icon = item['icon_url'];
|
final icon = item['icon_url'];
|
||||||
final bool isLast = index == items.length - 1;
|
final bool isLast = index == sortedItems.length - 1;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@ -737,7 +885,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
topic,
|
displayTopic,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@ -756,7 +904,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
value.toString(),
|
_wrapLtrTradeValue(value.toString()),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
@ -773,7 +921,11 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
rank.toString(),
|
_wrapLtrTradeValue(
|
||||||
|
rank_heading == 'Growth Rate'
|
||||||
|
? '${rank.toString()} %'
|
||||||
|
: rank.toString(),
|
||||||
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@ -894,18 +1046,9 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
final exportList = allTradeData['Export'] as List? ?? [];
|
final exportList = allTradeData['Export'] as List? ?? [];
|
||||||
final reexportList = allTradeData['Reexport'] as List? ?? [];
|
final reexportList = allTradeData['Reexport'] as List? ?? [];
|
||||||
|
|
||||||
importTotal = importList.fold<double>(
|
importTotal = _sumTradeEntries(importList);
|
||||||
0,
|
exportTotal = _sumTradeEntries(exportList);
|
||||||
(sum, item) => sum + parseTradeValue(item['value'] ?? ''),
|
reexportTotal = _sumTradeEntries(reexportList);
|
||||||
);
|
|
||||||
exportTotal = exportList.fold<double>(
|
|
||||||
0,
|
|
||||||
(sum, item) => sum + parseTradeValue(item['value'] ?? ''),
|
|
||||||
);
|
|
||||||
reexportTotal = reexportList.fold<double>(
|
|
||||||
0,
|
|
||||||
(sum, item) => sum + parseTradeValue(item['value'] ?? ''),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: use the summary tradeData list if detailed data is missing.
|
// Fallback: use the summary tradeData list if detailed data is missing.
|
||||||
for (var item in tradeData) {
|
for (var item in tradeData) {
|
||||||
@ -926,22 +1069,20 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final total = importTotal + exportTotal + reexportTotal;
|
|
||||||
|
|
||||||
final tabs = [
|
final tabs = [
|
||||||
{
|
{
|
||||||
'key': 'import',
|
'key': 'import',
|
||||||
'label': 'Total Import',
|
'label': context.translate('Total Import', 'إجمالي الاستيراد'),
|
||||||
'total': importTotal,
|
'total': importTotal,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'key': 'export',
|
'key': 'export',
|
||||||
'label': 'Total Export',
|
'label': context.translate('Total Export', 'إجمالي الصادرات'),
|
||||||
'total': exportTotal,
|
'total': exportTotal,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'key': 'reexport',
|
'key': 'reexport',
|
||||||
'label': 'Total ReExport',
|
'label': context.translate('Total ReExport', 'إجمالي إعادة التصدير'),
|
||||||
'total': reexportTotal,
|
'total': reexportTotal,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -966,6 +1107,28 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
tradeLabel = selectedTradeType;
|
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(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -973,7 +1136,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
|
|
||||||
/// 🔹 Title
|
/// 🔹 Title
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.competitiveness_uae_bilateral_trade,
|
bilateralTradeTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@ -995,7 +1158,9 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
color: isDarkTheme ? Colors.white : Color(0xFF000000)),
|
color: isDarkTheme ? Colors.white : Color(0xFF000000)),
|
||||||
),
|
),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '\$${formatTradeValue(total)}',
|
text: formatTradeValue(
|
||||||
|
importTotal + exportTotal + reexportTotal,
|
||||||
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@ -1030,11 +1195,15 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
selectedTradeType = typeKey;
|
selectedTradeType = typeKey;
|
||||||
|
|
||||||
if (typeKey == 'import') {
|
if (typeKey == 'import') {
|
||||||
tradeItems = allTradeData['Import'] ?? [];
|
tradeItems =
|
||||||
|
_topTradeEntries(allTradeData['Import'] ?? []);
|
||||||
} else if (typeKey == 'export') {
|
} else if (typeKey == 'export') {
|
||||||
tradeItems = allTradeData['Export'] ?? [];
|
tradeItems =
|
||||||
|
_topTradeEntries(allTradeData['Export'] ?? []);
|
||||||
} else if (typeKey == 'reexport') {
|
} else if (typeKey == 'reexport') {
|
||||||
tradeItems = allTradeData['Reexport'] ?? [];
|
tradeItems = _topTradeEntries(
|
||||||
|
allTradeData['Reexport'] ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -1192,7 +1361,8 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
Align(
|
Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: Text(
|
child: Text(
|
||||||
'\$${formatTradeValue(parseTradeValue(item['value'] ?? ''))}',
|
formatTradeDisplayValue(
|
||||||
|
(item['value'] ?? '').toString()),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@ -1205,7 +1375,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
SizedBox(height: 15),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -174,28 +174,25 @@ class _ContactState extends ConsumerState<Contact> {
|
|||||||
color: isDarkTheme ? Colors.black : Colors.grey[200],
|
color: isDarkTheme ? Colors.black : Colors.grey[200],
|
||||||
width: screenWidth,
|
width: screenWidth,
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
buildContactCard(context,
|
buildContactCard(context,
|
||||||
title: 'DUBAI - EMIRATES TOWER - 32ND FLOOR',
|
title: 'Federal Competitiveness and Statistics Centre',
|
||||||
titleAr: 'دبي - أبراج الإمارات - الطابق 32',
|
titleAr: 'المركز الاتحادي للتنافسية والإحصاء',
|
||||||
details: [
|
details: [
|
||||||
{
|
{
|
||||||
'icon': EnquiryAssetIconPath.building,
|
'icon': EnquiryAssetIconPath.building,
|
||||||
'text':
|
'text': 'Office: PO Box 127000, 32nd Floor, Jumeirah Emirates Towers, Dubai, United Arab Emirates',
|
||||||
'Office: Dubai - Emirates Tower - 32nd Floor',
|
'textAr': 'ص.ب. 127000، الطابق 32، أبراج الإمارات، دبي، الإمارات العربية المتحدة'
|
||||||
'textAr':
|
|
||||||
'المقر الرئيسي: دبي - أبراج الإمارات - الطابق 32'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'icon': EnquiryAssetIconPath.phone,
|
'icon': EnquiryAssetIconPath.phone,
|
||||||
'text': 'Tel: 00971 4608 0000',
|
'text': 'Tel: +971 4 608 0000',
|
||||||
'textAr': 'الهاتف: 0097146080000'
|
'textAr': 'الهاتف: +971 4 608 0000'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'icon': EnquiryAssetIconPath.fax,
|
'icon': EnquiryAssetIconPath.fax,
|
||||||
'text': 'Fax: 00971 4327 3535',
|
'text': 'Fax: +971 4 327 3535',
|
||||||
'textAr': 'فاكس: 0097143273535'
|
'textAr': 'فاكس: 0097143273535'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -220,97 +217,97 @@ class _ContactState extends ConsumerState<Contact> {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
isDarkTheme: isDarkTheme),
|
isDarkTheme: isDarkTheme),
|
||||||
const SizedBox(height: 10),
|
// const SizedBox(height: 10),
|
||||||
buildContactCard(
|
// buildContactCard(
|
||||||
context,
|
// context,
|
||||||
title: 'MEDIA SECTION',
|
// title: 'MEDIA SECTION',
|
||||||
titleAr: 'قسم الاعلام',
|
// titleAr: 'قسم الاعلام',
|
||||||
details: [
|
// details: [
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.building,
|
// 'icon': EnquiryAssetIconPath.building,
|
||||||
'text': 'Office: Media Section',
|
// 'text': 'Office: Media Section',
|
||||||
'textAr': 'المقر الرئيسي: قسم الاعلام'
|
// 'textAr': 'المقر الرئيسي: قسم الاعلام'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.phone,
|
// 'icon': EnquiryAssetIconPath.phone,
|
||||||
'text': 'Tel: 00971 4608 0000',
|
// 'text': 'Tel: +971 4 608 0000',
|
||||||
'textAr': 'الهاتف: 0097146080000'
|
// 'textAr': 'الهاتف: +971 4 608 0000'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.fax,
|
// 'icon': EnquiryAssetIconPath.fax,
|
||||||
'text': 'Fax: 00971 4327 3535',
|
// 'text': 'Fax: +971 4 327 3535',
|
||||||
'textAr': 'فاكس: 0097143273535'
|
// 'textAr': 'فاكس: 0097143273535'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.map,
|
// 'icon': EnquiryAssetIconPath.map,
|
||||||
'text': 'P.O.Box: 127000 Dubai',
|
// 'text': 'P.O.Box: 127000 Dubai',
|
||||||
'textAr': 'ص.ب: 127000 دبي'
|
// 'textAr': 'ص.ب: 127000 دبي'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.at,
|
// 'icon': EnquiryAssetIconPath.at,
|
||||||
'text': 'Email Address: GCO@fcsc.gov.ae',
|
// 'text': 'Email Address: GCO@fcsc.gov.ae',
|
||||||
'textAr': 'البريد الإلكتروني: GCO@fcsc.gov.ae'
|
// 'textAr': 'البريد الإلكتروني: GCO@fcsc.gov.ae'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.map,
|
// 'icon': EnquiryAssetIconPath.map,
|
||||||
'text': 'Latitude: 25.223926',
|
// 'text': 'Latitude: 25.223926',
|
||||||
'textAr': 'خط العرض: 25.223926'
|
// 'textAr': 'خط العرض: 25.223926'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.map,
|
// 'icon': EnquiryAssetIconPath.map,
|
||||||
'text': 'Longitude: 55.350526',
|
// 'text': 'Longitude: 55.350526',
|
||||||
'textAr': 'خط الطول: 55.350526'
|
// 'textAr': 'خط الطول: 55.350526'
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
isDarkTheme: isDarkTheme,
|
// isDarkTheme: isDarkTheme,
|
||||||
),
|
// ),
|
||||||
const SizedBox(height: 10),
|
// const SizedBox(height: 10),
|
||||||
buildContactCard(
|
// buildContactCard(
|
||||||
context,
|
// context,
|
||||||
title: 'SGDS',
|
// title: 'SGDS',
|
||||||
titleAr: 'أهداف التنمية المستدامة',
|
// titleAr: 'أهداف التنمية المستدامة',
|
||||||
details: [
|
// details: [
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.building,
|
// 'icon': EnquiryAssetIconPath.building,
|
||||||
'text': 'Office: SDGs',
|
// 'text': 'Office: SDGs',
|
||||||
'textAr': 'المقر الرئيسي: أهداف التنمية المستدامة'
|
// 'textAr': 'المقر الرئيسي: أهداف التنمية المستدامة'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.phone,
|
// 'icon': EnquiryAssetIconPath.phone,
|
||||||
'text': 'Tel: 00971 4608 0000',
|
// 'text': 'Tel: +971 4 608 0000',
|
||||||
'textAr': 'الهاتف: 0097146080000'
|
// 'textAr': 'الهاتف: +971 4 608 0000'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.fax,
|
// 'icon': EnquiryAssetIconPath.fax,
|
||||||
'text': 'Fax: 00971 4327 3535',
|
// 'text': 'Fax: +971 4 327 3535',
|
||||||
'textAr': 'فاكس : 0097143273535'
|
// 'textAr': 'فاكس : 0097143273535'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.map,
|
// 'icon': EnquiryAssetIconPath.map,
|
||||||
'text': 'P.O.Box: 127000 Dubai',
|
// 'text': 'P.O.Box: 127000 Dubai',
|
||||||
'textAr': 'ص.ب: 127000 دبي'
|
// 'textAr': 'ص.ب: 127000 دبي'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.at,
|
// 'icon': EnquiryAssetIconPath.at,
|
||||||
'text': 'Email Address: sdgs@fcsc.gov.ae',
|
// 'text': 'Email Address: sdgs@fcsc.gov.ae',
|
||||||
'textAr': 'البريد الإلكتروني: sdgs@fcsc.gov.ae'
|
// 'textAr': 'البريد الإلكتروني: sdgs@fcsc.gov.ae'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.map,
|
// 'icon': EnquiryAssetIconPath.map,
|
||||||
'text': 'Latitude: 25.223926',
|
// 'text': 'Latitude: 25.223926',
|
||||||
'textAr': 'خط العرض: 25.223926'
|
// 'textAr': 'خط العرض: 25.223926'
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
'icon': EnquiryAssetIconPath.map,
|
// 'icon': EnquiryAssetIconPath.map,
|
||||||
'text': 'Longitude: 55.350526',
|
// 'text': 'Longitude: 55.350526',
|
||||||
'textAr': 'خط الطول: 55.350526'
|
// 'textAr': 'خط الطول: 55.350526'
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
isDarkTheme: isDarkTheme,
|
// isDarkTheme: isDarkTheme,
|
||||||
),
|
// ),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
title: Text(context.translate('Contact Us', 'اتصل بنا')),
|
title: Text(context.translate('Contact Us', 'اتصل بنا')),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -107,7 +107,7 @@ class _FAQPageState extends ConsumerState<FAQPage> {
|
|||||||
return BaseScaffold(
|
return BaseScaffold(
|
||||||
dividerColor: Colors.grey[300],
|
dividerColor: Colors.grey[300],
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
showBackButton: true,
|
showBackButton: false,
|
||||||
// appbarColor: Colors.white,
|
// appbarColor: Colors.white,
|
||||||
backgroundColor: isDarkTheme ? Colors.black : Colors.white,
|
backgroundColor: isDarkTheme ? Colors.black : Colors.white,
|
||||||
appbarColor: isDarkTheme ? Colors.black : Colors.white,
|
appbarColor: isDarkTheme ? Colors.black : Colors.white,
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import 'package:uae_stat/config/theme/theme_provider.dart';
|
|||||||
import 'package:uae_stat/domain/use_cases/language.dart';
|
import 'package:uae_stat/domain/use_cases/language.dart';
|
||||||
import 'package:uae_stat/l10n/app_localizations.dart';
|
import 'package:uae_stat/l10n/app_localizations.dart';
|
||||||
import 'package:uae_stat/presentation/routes/drawer_routes/custom_drawer_routes.dart';
|
import 'package:uae_stat/presentation/routes/drawer_routes/custom_drawer_routes.dart';
|
||||||
import 'package:iconify_design/iconify_design.dart';
|
|
||||||
|
|
||||||
class Userguide extends ConsumerStatefulWidget {
|
class Userguide extends ConsumerStatefulWidget {
|
||||||
Userguide({super.key});
|
Userguide({super.key});
|
||||||
@ -17,27 +16,51 @@ class Userguide extends ConsumerStatefulWidget {
|
|||||||
class _UserguideState extends ConsumerState<Userguide> {
|
class _UserguideState extends ConsumerState<Userguide> {
|
||||||
late bool isDarkTheme;
|
late bool isDarkTheme;
|
||||||
|
|
||||||
final List<Map<String, dynamic>> guideList = [
|
final List<Map<String, String>> guideItems = const [
|
||||||
{
|
{
|
||||||
'routePath': 'aboutApp',
|
'title': 'About FCSC',
|
||||||
'color': Color(0xFF90B0D5),
|
'titleAr': 'نبذة عن المركز الاتحادي للتنافسية والإحصاء',
|
||||||
'text': 'Getting Started',
|
'path': '/user-guide/aboutApp/aboutFCSC',
|
||||||
'icon': 'cbi:start-tv',
|
|
||||||
'text-ar': 'البدء'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'routePath': 'features',
|
'title': 'Getting Started',
|
||||||
'color': Color(0xFFAA8E83),
|
'titleAr': 'البدء',
|
||||||
'text': 'Key Features',
|
'path': '/user-guide/aboutApp/getStarted',
|
||||||
'icon': 'pajamas:issue-type-feature',
|
|
||||||
'text-ar': 'الميزات الرئيسية'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'routePath': 'faq',
|
'title': 'App Features',
|
||||||
'color': Color(0xFF7296BE),
|
'titleAr': 'الميزات الرئيسية',
|
||||||
'text': 'FAQs',
|
'path': '/user-guide/features/appFeatures',
|
||||||
'icon': 'mdi:faq',
|
},
|
||||||
'text-ar': 'لأسئلة الشائعة'
|
{
|
||||||
|
'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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -47,43 +70,6 @@ class _UserguideState extends ConsumerState<Userguide> {
|
|||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentTheme = ref.watch(themeProvider);
|
final currentTheme = ref.watch(themeProvider);
|
||||||
@ -108,130 +94,35 @@ class _UserguideState extends ConsumerState<Userguide> {
|
|||||||
color: isDarkTheme ? Colors.white : Color(0xFF414042))),
|
color: isDarkTheme ? Colors.white : Color(0xFF414042))),
|
||||||
body: Container(
|
body: Container(
|
||||||
color: isDarkTheme ? Colors.black : Colors.white,
|
color: isDarkTheme ? Colors.black : Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
child: ListView.separated(
|
||||||
child: GridView.builder(
|
itemCount: guideItems.length,
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
separatorBuilder: (_, __) => Divider(
|
||||||
crossAxisCount: 2,
|
height: 1,
|
||||||
crossAxisSpacing: 10.0,
|
color: isDarkTheme ? Colors.grey : const Color(0xFFDEDEDE),
|
||||||
mainAxisSpacing: 10.0,
|
|
||||||
childAspectRatio: 1.7,
|
|
||||||
// mainAxisExtent:MediaQuery.of(context).size.height* 0.15 ,
|
|
||||||
),
|
),
|
||||||
itemCount: guideList.length,
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return HoverContainer(
|
final item = guideItems[index];
|
||||||
iconName: guideList[index]['icon'],
|
return ListTile(
|
||||||
title: context.translate(
|
contentPadding:
|
||||||
guideList[index]['text'], guideList[index]['text-ar']),
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||||
routePath: guideList[index]['routePath'],
|
title: Text(
|
||||||
);
|
context.translate(item['title']!, item['titleAr']!),
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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<HoverContainer> createState() => _HoverContainerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HoverContainerState extends ConsumerState<HoverContainer> {
|
|
||||||
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(
|
style: TextStyle(
|
||||||
color: Colors.white, // Text color
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w500,
|
||||||
fontFamily: context.translate(
|
color: isDarkTheme ? Colors.white : const Color(0xFF414042),
|
||||||
'Roboto',
|
fontFamily: context.translate('Roboto', 'NotoKufi'),
|
||||||
'NotoKufi',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
trailing: Icon(
|
||||||
|
Icons.keyboard_arrow_right,
|
||||||
|
color: isDarkTheme ? const Color(0xFF898C81) : const Color(0xFF898C81),
|
||||||
),
|
),
|
||||||
),
|
onTap: () => context.push(item['path']!),
|
||||||
],
|
|
||||||
);
|
);
|
||||||
})),
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,14 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:pocketbase/pocketbase.dart';
|
import 'package:pocketbase/pocketbase.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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:tutorial_coach_mark/tutorial_coach_mark.dart';
|
||||||
import 'package:uae_stat/config/injector.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/img_asset_paths/icons/bottom_bar_asset_icon_path.dart';
|
||||||
import 'package:uae_stat/infrastructure/services/pocketbase_service.dart';
|
import 'package:uae_stat/infrastructure/services/pocketbase_service.dart';
|
||||||
import 'package:uae_stat/l10n/app_localizations.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/indicators/locale_provider.dart';
|
||||||
import 'package:uae_stat/presentation/components/my_toggle.dart';
|
import 'package:uae_stat/presentation/components/my_toggle.dart';
|
||||||
@ -956,6 +959,22 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
|||||||
MediaQuery.of(context).platformBrightness == Brightness.dark);
|
MediaQuery.of(context).platformBrightness == Brightness.dark);
|
||||||
|
|
||||||
final String currentRoute1 = ModalRoute.of(context)?.settings.name ?? '';
|
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(
|
return Scaffold(
|
||||||
backgroundColor: widget.backgroundColor,
|
backgroundColor: widget.backgroundColor,
|
||||||
@ -1416,6 +1435,21 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
|||||||
),
|
),
|
||||||
onTap: () => _navigateTo(context, '/user-guide'),
|
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(
|
ListTile(
|
||||||
leading: Icon(Icons.mail_outlined,
|
leading: Icon(Icons.mail_outlined,
|
||||||
color: isDarkTheme ? Color(0xFFFFFFFF) : null),
|
color: isDarkTheme ? Color(0xFFFFFFFF) : null),
|
||||||
@ -1587,8 +1621,8 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
|||||||
BottomBarAssetIconPath.home,
|
BottomBarAssetIconPath.home,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
color: _getSelectedIndex(currentRoute) == 0
|
color: _getSelectedIndex(currentRoute) == 0
|
||||||
? Color(0xFFAA8E83)
|
? bottomNavActiveColor
|
||||||
: Color(0xFF989898),
|
: bottomNavInactiveColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -1606,8 +1640,8 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
|||||||
BottomBarAssetIconPath.uaeMap,
|
BottomBarAssetIconPath.uaeMap,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
color: _getSelectedIndex(currentRoute) == 1
|
color: _getSelectedIndex(currentRoute) == 1
|
||||||
? Color(0xFFAA8E83)
|
? bottomNavActiveColor
|
||||||
: Color(0xFF989898),
|
: bottomNavInactiveColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -1624,8 +1658,8 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
|||||||
BottomBarAssetIconPath.ranking,
|
BottomBarAssetIconPath.ranking,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
color: _getSelectedIndex(currentRoute) == 2
|
color: _getSelectedIndex(currentRoute) == 2
|
||||||
? Color(0xFFAA8E83)
|
? bottomNavActiveColor
|
||||||
: Color(0xFF989898),
|
: bottomNavInactiveColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -1642,8 +1676,8 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
|||||||
BottomBarAssetIconPath.globe,
|
BottomBarAssetIconPath.globe,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
color: _getSelectedIndex(currentRoute) == 3
|
color: _getSelectedIndex(currentRoute) == 3
|
||||||
? Color(0xFFAA8E83)
|
? bottomNavActiveColor
|
||||||
: Color(0xFF989898),
|
: bottomNavInactiveColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -1652,8 +1686,8 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
key: bottomNavKey,
|
key: bottomNavKey,
|
||||||
selectedItemColor: Color(0xFFAA8E83),
|
selectedItemColor: bottomNavActiveColor,
|
||||||
unselectedItemColor: Color(0xFF989898),
|
unselectedItemColor: bottomNavInactiveColor,
|
||||||
selectedLabelStyle: TextStyle(
|
selectedLabelStyle: TextStyle(
|
||||||
fontFamily: context.translate(
|
fontFamily: context.translate(
|
||||||
'Roboto',
|
'Roboto',
|
||||||
@ -1812,12 +1846,62 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
|||||||
|
|
||||||
final PAuthRepo _authRepo = PAuthRepo();
|
final PAuthRepo _authRepo = PAuthRepo();
|
||||||
|
|
||||||
|
Future<void> _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<void>(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => OAuthWebView(
|
||||||
|
authUrl: logoutUri.toString(),
|
||||||
|
useUaePassDeepLink: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Keep local logout flow even if UAE PASS logout launch fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> logout(BuildContext context) async {
|
Future<void> logout(BuildContext context) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
// await _logoutFromUaePassIfNeeded(context);
|
||||||
await _authRepo.logout();
|
await _authRepo.logout();
|
||||||
prefs.clear();
|
await prefs.clear();
|
||||||
|
if (context.mounted) {
|
||||||
context.go('/login'); // Redirect to login after logout
|
context.go('/login'); // Redirect to login after logout
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showLogoutConfirmationDialog(BuildContext context, isDarkTheme) {
|
void _showLogoutConfirmationDialog(BuildContext context, isDarkTheme) {
|
||||||
double myheight = MediaQuery.of(context).size.height;
|
double myheight = MediaQuery.of(context).size.height;
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
name: uae_stat
|
name: uae_stat
|
||||||
description: "View statistics about the UAE from the Federal Center for Statistics and Competitiveness."
|
description: "View statistics about the UAE from the Federal Center for Statistics and Competitiveness."
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
#version: 1.2.18+26
|
#version: 1.2.26+34
|
||||||
version: 1.2.16+24
|
version: 1.2.24+32
|
||||||
#version: 1.0.16+17
|
#version: 1.0.16+17
|
||||||
#version: 1.0.6+6
|
#version: 1.0.6+6
|
||||||
|
|
||||||
@ -173,6 +173,7 @@ flutter:
|
|||||||
- assets/icons/uae_numbers/ShareDark.png
|
- assets/icons/uae_numbers/ShareDark.png
|
||||||
- assets/icons/uae_numbers/ShareLight.png
|
- assets/icons/uae_numbers/ShareLight.png
|
||||||
- assets/icons/misc/successMsg.png
|
- assets/icons/misc/successMsg.png
|
||||||
|
- assets/uae_pass_button/
|
||||||
|
|
||||||
fonts:
|
fonts:
|
||||||
- family: Segoe
|
- family: Segoe
|
||||||
|
|||||||