new design
@ -20,12 +20,12 @@ if (project.hasProperty('google-services.json')) {
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty("flutter.versionCode")
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = "26"
|
||||
flutterVersionCode = "34"
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty("flutter.versionName")
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = "1.2.18"
|
||||
flutterVersionName = "1.2.26"
|
||||
}
|
||||
|
||||
def keystorePropertiesFile = rootProject.file("key.properties")
|
||||
|
||||
@ -52,6 +52,7 @@
|
||||
android:host="resume_authn" />
|
||||
</intent-filter>
|
||||
|
||||
|
||||
<!-- AppsFlyer OneLink fallback: fcscappstates://callback?deep_link_value=... (matches dashboard) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@ -114,6 +115,22 @@
|
||||
<package android:name="ae.uaepass.mainapp" />
|
||||
<package android:name="ae.uaepass.mainapp.qa" />
|
||||
<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>
|
||||
<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)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.16;
|
||||
MARKETING_VERSION = 1.2.24;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@ -707,7 +707,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.16;
|
||||
MARKETING_VERSION = 1.2.24;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@ -737,7 +737,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.16;
|
||||
MARKETING_VERSION = 1.2.24;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ae.gov.fcsc.frontend.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
|
||||
@ -10,4 +10,20 @@ import UIKit
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
// Called by iOS when UAE PASS app redirects back to your app scheme.
|
||||
// aegovfcscstats://resume_authn?url=<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>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ae.gov.fcsc.frontend.ios</string>
|
||||
<string>aegovfcscfrontendios</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- UAE PASS resume_authn callback: ae.gov.fcsc.stats://resume_authn?url=... -->
|
||||
@ -44,7 +44,7 @@
|
||||
<string>ae.gov.fcsc.stats</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ae.gov.fcsc.stats</string>
|
||||
<string>aegovfcscstats</string>
|
||||
</array>
|
||||
<key>CFBundleRole</key>
|
||||
<string>Editor</string>
|
||||
|
||||
@ -3,3 +3,9 @@
|
||||
// const String apiUrl = 'https://pbdev.venbait.in/';
|
||||
const String apiUrl = 'https://pb.venbait.in/';
|
||||
// const String apiUrl = 'https://pocket.fcsc.gov.ae/';
|
||||
|
||||
/// When `true`, UAE PASS deep links open the **staging** app (`uaepassstg://`).
|
||||
/// When `false`, the scheme from the IdP is preserved (`uaepass://` for production).
|
||||
/// Set to `true` if PocketBase OIDC / UAE PASS IdP is configured for staging
|
||||
/// (e.g. `stg-id.uaepass.ae` / `stg-ids.uaepass.ae`).
|
||||
const bool uaePassUseStagingEnvironment = true;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:uae_stat/config/sessionCheckScreen.dart';
|
||||
@ -12,7 +13,6 @@ import 'package:uae_stat/presentation/Screens/auth_verification/registration.dar
|
||||
import 'package:uae_stat/presentation/Screens/auth_verification/terms&conditions.dart';
|
||||
import 'package:uae_stat/presentation/Screens/charts/screens/chart_screen.dart';
|
||||
import 'package:uae_stat/presentation/Screens/online_offline_verification/internet_check.dart';
|
||||
import 'package:uae_stat/presentation/routes/auth_routes/checkingUAEPass.dart';
|
||||
import 'package:uae_stat/presentation/routes/auth_routes/fcsc_profile_linking.dart';
|
||||
import 'package:uae_stat/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/Detailedcompetitiveness.dart';
|
||||
import 'package:uae_stat/presentation/routes/bottom_bar_routes/tab_routes/Competitiveness/competitiveness.dart';
|
||||
@ -37,7 +37,6 @@ import 'package:uae_stat/presentation/routes/drawer_routes/Drawer%20Items/user_g
|
||||
import '../presentation/Screens/auth_verification/create_new_pw.dart';
|
||||
import '../presentation/Screens/profilepage.dart';
|
||||
import '../presentation/routes/auth_routes/login_route.dart';
|
||||
import '../presentation/routes/auth_routes/uae_pass_resume_auth_screen.dart';
|
||||
import 'package:uae_stat/presentation/routes/drawer_routes/Drawer%20Items/bookmark.dart';
|
||||
import '../presentation/routes/drawer_routes/Drawer Items/edit_profile.dart';
|
||||
import '../presentation/routes/drawer_routes/Drawer Items/feedback.dart';
|
||||
@ -114,13 +113,6 @@ final GoRouter router = GoRouter(
|
||||
// path: '/register',
|
||||
// builder: (context, state) => RegisterScreen(),
|
||||
// ),
|
||||
GoRoute(
|
||||
path: '/resume_authn',
|
||||
builder: (context, state) {
|
||||
final urlToLoad = state.uri.queryParameters['url'];
|
||||
return UaePassResumeAuthScreen(urlToLoad: urlToLoad);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/fcscprofilelinking',
|
||||
builder: (context, state) {
|
||||
@ -140,10 +132,7 @@ final GoRouter router = GoRouter(
|
||||
path: '/myhomepage',
|
||||
builder: (context, state) => MyHomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/checkingUAEPass',
|
||||
builder: (context, state) => checkingUAEPass(),
|
||||
),
|
||||
|
||||
|
||||
// GoRoute(
|
||||
// path: '/DemoHome/:dataSets',
|
||||
@ -417,6 +406,10 @@ final GoRouter router = GoRouter(
|
||||
path: '/contact',
|
||||
builder: (context, state) => Contact(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/faq',
|
||||
builder: (context, state) => FAQPage(),
|
||||
),
|
||||
],
|
||||
redirect: (context, state) async {
|
||||
// If UAE PASS redirected here with ?code=
|
||||
|
||||
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? redirectUrl;
|
||||
String? providerName;
|
||||
String? oauthCode;
|
||||
String? oauthState;
|
||||
|
||||
/// BCP47 tag passed as OIDC `ui_locales` (e.g. `ar`, `en`) so UAE PASS login
|
||||
/// UI matches the language the user chose in the app. Read after auth to set
|
||||
@ -39,6 +41,8 @@ class UaePassOAuthState {
|
||||
codeVerifier = null;
|
||||
redirectUrl = null;
|
||||
providerName = null;
|
||||
oauthCode = null;
|
||||
oauthState = null;
|
||||
pendingUiLocales = null;
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,27 @@
|
||||
abstract class MiscIconAssetPath {
|
||||
static const _basePath = 'assets/icons/misc';
|
||||
static const _uaePassButtonBasePath = 'assets/uae_pass_button';
|
||||
static const rightChevronInSemiCircle =
|
||||
'$_basePath/right_chevron_in_semicircle.png';
|
||||
static const signInWithGoogle = '$_basePath/signInWithGoogle.png';
|
||||
static const signInWithApple = '$_basePath/signInWithApple.png';
|
||||
static const signInWithUAEPass = '$_basePath/signInWithUAEPass.png';
|
||||
// static const signInWithUAEPass = '$_basePath/signInWithUAEPass.png';
|
||||
static const signInWithUAEPassEnDark =
|
||||
'$_uaePassButtonBasePath/UAEPASS_Sign_in_Btn_Outline_Active.svg';
|
||||
static const signInWithUAEPassArDark =
|
||||
'$_uaePassButtonBasePath/AR_UAEPASS_Sign_in_Btn_Outline_Active.svg';
|
||||
static const signInWithUAEPassEnLight =
|
||||
'$_uaePassButtonBasePath/UAEPASS_Sign_in_Btn_Outline_Active.svg';
|
||||
static const signInWithUAEPassArLight =
|
||||
'$_uaePassButtonBasePath/AR_UAEPASS_Sign_in_Btn_Outline_Active.svg';
|
||||
static const signUpWithUAEPassEnDark =
|
||||
'$_uaePassButtonBasePath/UAEPASS_Sign_up_Btn_Outline_Active.svg';
|
||||
static const signUpWithUAEPassArDark =
|
||||
'$_uaePassButtonBasePath/AR_UAEPASS_Sign_up_Btn_Outline_Active.svg';
|
||||
static const signUpWithUAEPassEnLight =
|
||||
'$_uaePassButtonBasePath/UAEPASS_Sign_up_Btn_Outline_Active.svg';
|
||||
static const signUpWithUAEPassArLight =
|
||||
'$_uaePassButtonBasePath/AR_UAEPASS_Sign_up_Btn_Outline_Active.svg';
|
||||
static const lock = '$_basePath/lock.png';
|
||||
static const lockDark = '$_basePath/lockDark.png';
|
||||
static const lockLight = '$_basePath/lockLight.png';
|
||||
|
||||
@ -8,6 +8,10 @@ abstract class PocketBaseService {
|
||||
// static const _host = 'https://pb.venbait.in';
|
||||
// static const _host = 'http://127.0.0.1:8090';
|
||||
static final _pb = PocketBase(_host);
|
||||
|
||||
/// Single shared client — OAuth/SSE state must match [authStore] everywhere.
|
||||
static PocketBase get client => _pb;
|
||||
|
||||
static final users = _pb.collection('users');
|
||||
static final authStore = _pb.authStore;
|
||||
}
|
||||
|
||||
@ -518,6 +518,18 @@ abstract class AppLocalizations {
|
||||
/// **'Rank'**
|
||||
String get competitiveness_rank;
|
||||
|
||||
/// No description provided for @competitiveness_score_in.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Score in'**
|
||||
String get competitiveness_score_in;
|
||||
|
||||
/// No description provided for @competitiveness_score.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Score'**
|
||||
String get competitiveness_score;
|
||||
|
||||
/// No description provided for @competitiveness_previous_edition.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@ -551,7 +563,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @competitiveness_uae_bilateral_trade.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'UAE Bilateral Trade (2022)'**
|
||||
/// **'UAE Bilateral Trade'**
|
||||
String get competitiveness_uae_bilateral_trade;
|
||||
|
||||
/// No description provided for @competitiveness_total.
|
||||
|
||||
@ -223,6 +223,12 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get competitiveness_rank => 'رتبة';
|
||||
|
||||
@override
|
||||
String get competitiveness_score_in => 'سجل في';
|
||||
|
||||
@override
|
||||
String get competitiveness_score => 'نتيجة';
|
||||
|
||||
@override
|
||||
String get competitiveness_previous_edition => 'مقابل الإصدار السابق';
|
||||
|
||||
@ -242,7 +248,7 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get competitiveness_uae_bilateral_trade =>
|
||||
'التجارة الثنائية لدولة الإمارات العربية المتحدة (2022)';
|
||||
'التجارة الثنائية لدولة الإمارات العربية المتحدة';
|
||||
|
||||
@override
|
||||
String get competitiveness_total => 'المجموع ';
|
||||
|
||||
@ -223,6 +223,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get competitiveness_rank => 'Rank';
|
||||
|
||||
@override
|
||||
String get competitiveness_score_in => 'Score in';
|
||||
|
||||
@override
|
||||
String get competitiveness_score => 'Score';
|
||||
|
||||
@override
|
||||
String get competitiveness_previous_edition => 'vs. Previous Edition';
|
||||
|
||||
@ -240,8 +246,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get competitiveness_multi_countries => 'MULTIPLE COUNTRIES';
|
||||
|
||||
@override
|
||||
String get competitiveness_uae_bilateral_trade =>
|
||||
'UAE Bilateral Trade (2022)';
|
||||
String get competitiveness_uae_bilateral_trade => 'UAE Bilateral Trade';
|
||||
|
||||
@override
|
||||
String get competitiveness_total => 'Total ';
|
||||
|
||||
@ -78,12 +78,14 @@
|
||||
"competitiveness_heading": "التقارير التنافسية",
|
||||
"competitiveness_rank_in": "الترتيب في",
|
||||
"competitiveness_rank": "رتبة",
|
||||
"competitiveness_score_in": "سجل في",
|
||||
"competitiveness_score": "نتيجة",
|
||||
"competitiveness_previous_edition": "مقابل الإصدار السابق",
|
||||
"competitiveness_first_globally": "أولا عالميا",
|
||||
"competitiveness_first_in_GCC": "الأول في دول مجلس التعاون الخليجي",
|
||||
"competitiveness_first_in_arab_countries": "الأول في دول مجلس التعاون الخليجي",
|
||||
"competitiveness_multi_countries": "دول متعددة",
|
||||
"competitiveness_uae_bilateral_trade":"التجارة الثنائية لدولة الإمارات العربية المتحدة (2022)",
|
||||
"competitiveness_uae_bilateral_trade":"التجارة الثنائية لدولة الإمارات العربية المتحدة",
|
||||
"competitiveness_total": "المجموع ",
|
||||
"country_profile": "ملف تعريف الدولة",
|
||||
|
||||
|
||||
@ -76,12 +76,14 @@
|
||||
"competitiveness_heading": "Competitive Reports",
|
||||
"competitiveness_rank_in": "Rank in",
|
||||
"competitiveness_rank": "Rank",
|
||||
"competitiveness_score_in": "Score in",
|
||||
"competitiveness_score": "Score",
|
||||
"competitiveness_previous_edition": "vs. Previous Edition",
|
||||
"competitiveness_first_globally": "First Globally",
|
||||
"competitiveness_first_in_GCC": "First in GCC",
|
||||
"competitiveness_first_in_arab_countries": "First in Arab Countries",
|
||||
"competitiveness_multi_countries": "MULTIPLE COUNTRIES",
|
||||
"competitiveness_uae_bilateral_trade": "UAE Bilateral Trade (2022)",
|
||||
"competitiveness_uae_bilateral_trade": "UAE Bilateral Trade",
|
||||
"competitiveness_total": "Total ",
|
||||
"country_profile": "Country Profile",
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
@ -27,8 +28,9 @@ import 'package:uae_stat/presentation/components/dialogs.dart';
|
||||
|
||||
import 'package:uae_stat/presentation/components/indicators/locale_provider.dart';
|
||||
import 'package:uae_stat/presentation/components/my_toggle.dart';
|
||||
import 'package:uae_stat/presentation/routes/auth_routes/oauth_webview.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../routes/auth_routes/login_route.dart';
|
||||
import '../../routes/auth_routes/login_route.dart' hide OAuthWebView;
|
||||
|
||||
class RegisterScreen extends ConsumerStatefulWidget {
|
||||
final String? fromPage;
|
||||
@ -316,7 +318,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
'role': 'user',
|
||||
// 'status': 'Approved',
|
||||
// 'user_mail_verify': true,
|
||||
'emiratesid': uuid,
|
||||
'uuid': uuid,
|
||||
'is_oauth_login': 1,
|
||||
},
|
||||
headers: {
|
||||
@ -538,7 +540,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
final existing = await pb.collection('users').getList(
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
filter: 'emiratesid = "$uuid"',
|
||||
filter: 'uuid = "$uuid"',
|
||||
);
|
||||
if (existing.items.isEmpty) {
|
||||
// First, persist a valid session so protected routes (like /profile)
|
||||
@ -844,6 +846,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
double screenwidth = MediaQuery.of(context).size.width;
|
||||
final registerLocale = ref.watch(localeProvider);
|
||||
cameFromLinking = widget.fromPage == 'fcscprofilelinking';
|
||||
|
||||
if (cameFromLinking){
|
||||
setState(() {
|
||||
_emailController.text = widget.userInfo?['email']?.toString().trim() ?? '';
|
||||
_usernameController.text = widget.userInfo?['firstnameEN']!.toString().trim() ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
ref.listen<AsyncValue<bool>>(connectivityProvider, (previous, hasInternet) {
|
||||
if (hasInternet.value == false) {
|
||||
@ -863,6 +874,32 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
height: 40,
|
||||
);
|
||||
|
||||
final uaePassSignUpButtonAsset = registerLocale?.languageCode == 'ar'
|
||||
? (isDarkTheme
|
||||
? MiscIconAssetPath.signUpWithUAEPassArDark
|
||||
: MiscIconAssetPath.signUpWithUAEPassArLight)
|
||||
: (isDarkTheme
|
||||
? MiscIconAssetPath.signUpWithUAEPassEnDark
|
||||
: MiscIconAssetPath.signUpWithUAEPassEnLight);
|
||||
final signUpWithUAEPassBtn = Semantics(
|
||||
button: true,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () async {
|
||||
await oAuthGoogleAndAppleLogin(context, ref, 'oidc');
|
||||
},
|
||||
child: SvgPicture.asset(
|
||||
uaePassSignUpButtonAsset,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.fitWidth,
|
||||
excludeFromSemantics: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget buildIconContainer(IconData icon, Color iconColor) {
|
||||
return Container(
|
||||
width: 60,
|
||||
@ -1978,111 +2015,64 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 5.0,
|
||||
bottom: 0,
|
||||
left: 30,
|
||||
right: 30),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Row(
|
||||
children: [
|
||||
// Left line
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.grey.shade400,
|
||||
thickness: 1,
|
||||
endIndent: 10,
|
||||
),
|
||||
),
|
||||
|
||||
// Center text
|
||||
Text(
|
||||
AppLocalizations.of(context)!.or,
|
||||
style: TextStyle(
|
||||
color: isDarkTheme
|
||||
? Colors.white
|
||||
: Color(0x33000000),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
|
||||
// Right line
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.grey.shade400,
|
||||
thickness: 1,
|
||||
indent: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Padding(
|
||||
if (!cameFromLinking) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 5.0,
|
||||
bottom: 0,
|
||||
left: 30,
|
||||
right: 30),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
await oAuthGoogleAndAppleLogin(context, ref, 'oidc');
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(
|
||||
color: Color(0xFF000000)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
vertical: 16.5),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
textStyle: TextStyle(
|
||||
fontFamily: context.translate(
|
||||
'Roboto', 'NotoKufi'),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
width: double.infinity,
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
MiscIconAssetPath
|
||||
.signInWithUAEPass, // replace with your Google icon asset
|
||||
height: 20,
|
||||
// Left line
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.grey.shade400,
|
||||
thickness: 1,
|
||||
endIndent: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// Center text
|
||||
Text(
|
||||
AppLocalizations.of(context)!.uaepass_sign_in,
|
||||
textAlign: TextAlign.center,
|
||||
AppLocalizations.of(context)!.or,
|
||||
style: TextStyle(
|
||||
color: Color(0xFF000000),
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
color: isDarkTheme
|
||||
? Colors.white
|
||||
: Color(0x33000000),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
|
||||
// Right line
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.grey.shade400,
|
||||
thickness: 1,
|
||||
indent: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
|
||||
if (!cameFromLinking) ...[
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 5.0,
|
||||
bottom: 0,
|
||||
left: 30,
|
||||
right: 30,
|
||||
),
|
||||
child: signUpWithUAEPassBtn,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Text(
|
||||
AppLocalizations.of(context)!
|
||||
|
||||
@ -177,6 +177,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
setState(() {
|
||||
_usernameController.text = userDetailsResponse.data['uname'] ?? '';
|
||||
_emailController.text = userDetailsResponse.data['email'] ?? '';
|
||||
_fullNameController.text = userDetailsResponse.data['full_name'] ?? '';
|
||||
role = userDetailsResponse.data['role'] ?? '';
|
||||
isOAuthLogin = userDetailsResponse.data['is_oauth_login'] ?? '';
|
||||
});
|
||||
|
||||
@ -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 ThemeMode currentTheme;
|
||||
List<dynamic> reports = [];
|
||||
int currentRank = 0;
|
||||
int previousRank = 0;
|
||||
num currentRank = 0;
|
||||
num previousRank = 0;
|
||||
bool isPositive = false;
|
||||
Map<String, dynamic> selectedReport = {};
|
||||
|
||||
@ -43,6 +43,11 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
||||
List gccList = [];
|
||||
List arabList = [];
|
||||
|
||||
String _formatRankValue(num value) {
|
||||
if (value == 0) return '-';
|
||||
return value % 1 == 0 ? value.toInt().toString() : value.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -295,7 +300,7 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'${AppLocalizations.of(context)!.competitiveness_rank_in} ${selectedReport['year']}',
|
||||
'${selectedReport['is_score'] == false ? AppLocalizations.of(context)!.competitiveness_rank_in : AppLocalizations.of(context)!.competitiveness_score_in} ${selectedReport['year']}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDarkTheme
|
||||
@ -308,7 +313,7 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
currentRank.toString(),
|
||||
_formatRankValue(currentRank),
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w900,
|
||||
@ -317,7 +322,9 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
||||
: Color(0xFF000000)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (currentRank != previousRank)
|
||||
if (currentRank > 0 &&
|
||||
previousRank > 0 &&
|
||||
currentRank != previousRank)
|
||||
Icon(
|
||||
isPositive
|
||||
? Icons.arrow_upward
|
||||
@ -348,7 +355,7 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
previousRank.toString(),
|
||||
_formatRankValue(previousRank),
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w900,
|
||||
|
||||
@ -240,30 +240,41 @@ class _CompetitivenessState extends ConsumerState<Competitiveness> {
|
||||
child: Row(
|
||||
children: [
|
||||
/// Rank Section
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.competitiveness_rank,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
SizedBox(
|
||||
width: 68,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
report['is_score'] == false
|
||||
? AppLocalizations.of(
|
||||
context,
|
||||
)!.competitiveness_rank
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
)!.competitiveness_score,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
report['current_year_rank']
|
||||
.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
Text(
|
||||
report['current_year_rank']
|
||||
.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
@ -15,6 +15,7 @@ import 'package:uae_stat/domain/use_cases/language.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DetailedCountryProfilePage extends ConsumerStatefulWidget {
|
||||
final String countryName;
|
||||
@ -212,70 +213,77 @@ class _DetailedCountryProfilePageState
|
||||
),
|
||||
showBackButton: true,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Country name and flag (Header Section)
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: flagUrl.isEmpty
|
||||
? const Center(
|
||||
child: Icon(Icons.flag, size: 18),
|
||||
)
|
||||
: flagUrl.endsWith('.svg')
|
||||
? SvgPicture.network(
|
||||
flagUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholderBuilder: (_) => const Center(
|
||||
child: SizedBox(
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: CircularProgressIndicator(strokeWidth: 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Image.network(
|
||||
flagUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Center(
|
||||
child: Icon(Icons.flag, size: 18),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Country name and flag (Header Section)
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
spreadRadius: 2,
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: flagUrl.isEmpty
|
||||
? const Center(
|
||||
child: Icon(Icons.flag, size: 18),
|
||||
)
|
||||
: flagUrl.endsWith('.svg')
|
||||
? SvgPicture.network(
|
||||
flagUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholderBuilder: (_) => const Center(
|
||||
child: SizedBox(
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Image.network(
|
||||
flagUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const Center(
|
||||
child: Icon(Icons.flag, size: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
(countryData?['country_name'] ?? '').toString().toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDarkTheme ? Colors.white : Color(0xFF111111),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
(countryData?['country_name'] ?? '')
|
||||
.toString()
|
||||
.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDarkTheme ? Colors.white : Color(0xFF111111),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Government Officials (Grid Section)
|
||||
leaders.isEmpty
|
||||
@ -303,7 +311,7 @@ class _DetailedCountryProfilePageState
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 12), // Further reduced spacing
|
||||
const SizedBox(height: 12), // Further reduced spacing
|
||||
|
||||
// Category Buttons (Button Section)
|
||||
statistics == null
|
||||
@ -320,7 +328,7 @@ class _DetailedCountryProfilePageState
|
||||
children: availableCategories.map((key) {
|
||||
final isBilateral = key == 'bilateral_trade';
|
||||
|
||||
return SizedBox(
|
||||
final categoryButton = SizedBox(
|
||||
width: isBilateral
|
||||
? double.infinity
|
||||
: (MediaQuery.of(context).size.width - 48) /
|
||||
@ -338,12 +346,52 @@ class _DetailedCountryProfilePageState
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return categoryButton;
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (statistics != null && statistics!.containsKey('bilateral_trade'))
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
child: _buildBilateralTradeLastUpdated(isDarkTheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBilateralTradeLastUpdated(bool isDarkTheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkTheme ? const Color(0xFF222222) : const Color(0xFFF4F4F4),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
context.translate(
|
||||
'Last updated: 01 Mar 2026',
|
||||
'آخر تحديث: 01 مارس 2026',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDarkTheme ? const Color(0xFFFFFFFF) : const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -388,37 +436,37 @@ class __LeaderCardState extends ConsumerState<_LeaderCard> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: widget.imagePath.startsWith('http') &&
|
||||
widget.imagePath.isNotEmpty
|
||||
widget.imagePath.isNotEmpty
|
||||
? Image.network(
|
||||
widget.imagePath,
|
||||
width: 70,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 70,
|
||||
height: 80,
|
||||
color: isDarkTheme
|
||||
? const Color(0xFF222222)
|
||||
: const Color(0xFFF2F2F2),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
size: 36,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
)
|
||||
widget.imagePath,
|
||||
width: 70,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 70,
|
||||
height: 80,
|
||||
color: isDarkTheme
|
||||
? const Color(0xFF222222)
|
||||
: const Color(0xFFF2F2F2),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
size: 36,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 70,
|
||||
height: 80,
|
||||
color: isDarkTheme
|
||||
? const Color(0xFF222222)
|
||||
: const Color(0xFFF2F2F2),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
size: 36,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
width: 70,
|
||||
height: 80,
|
||||
color: isDarkTheme
|
||||
? const Color(0xFF222222)
|
||||
: const Color(0xFFF2F2F2),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
size: 36,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@ -476,7 +524,8 @@ void showCategoryPopup(BuildContext context, String category,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.8; // 👈 80% height
|
||||
final maxHeight =
|
||||
MediaQuery.of(context).size.height * 0.8; // 👈 80% height
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
@ -497,7 +546,8 @@ void showCategoryPopup(BuildContext context, String category,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SingleChildScrollView( // ✅ SCROLL ENABLED
|
||||
child: SingleChildScrollView(
|
||||
// ✅ SCROLL ENABLED
|
||||
child: CategoryPopupContent(
|
||||
category: category,
|
||||
statistics: statistics,
|
||||
@ -587,6 +637,9 @@ class CategoryPopupContent extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
static final NumberFormat _tradeNumberFormat = NumberFormat('#,##0.00');
|
||||
|
||||
String _wrapLtrTradeValue(String value) => '\u2066$value\u2069';
|
||||
String selectedTradeType = 'import';
|
||||
bool isLoadingTrade = false;
|
||||
List<dynamic> tradeItems = [];
|
||||
@ -602,7 +655,6 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
'إجمالي إعادة التصدير': 'reexport',
|
||||
};
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -636,9 +688,9 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'${apiUrl}api/getBilateralTradeData'
|
||||
'?country_code=$country_code'
|
||||
'&statistics_id=$statisticsID'
|
||||
'&language=$locale',
|
||||
'?country_code=$country_code'
|
||||
'&statistics_id=$statisticsID'
|
||||
'&language=$locale',
|
||||
),
|
||||
headers: {'APP_SIGNATURE': 'fcsc.gov.ae.X7pL9qZm2A'},
|
||||
);
|
||||
@ -648,7 +700,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
setState(() {
|
||||
allTradeData = decoded;
|
||||
|
||||
tradeItems = decoded['Import'] ?? [];
|
||||
tradeItems = _topTradeEntries(decoded[_tradeApiKey(type)] ?? []);
|
||||
|
||||
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**.
|
||||
double parseTradeValue(String value) {
|
||||
final upper = value.toUpperCase();
|
||||
double multiplier = 1.0; // default: already in millions
|
||||
|
||||
if (upper.contains(' B')) {
|
||||
// 1.5 B = 1500 M
|
||||
multiplier = 1000.0;
|
||||
} else if (upper.contains(' M')) {
|
||||
multiplier = 1.0;
|
||||
String _tradeApiKey(String typeKey) {
|
||||
switch (typeKey) {
|
||||
case 'export':
|
||||
return 'Export';
|
||||
case 'reexport':
|
||||
return 'Reexport';
|
||||
case 'import':
|
||||
default:
|
||||
return 'Import';
|
||||
}
|
||||
|
||||
final cleaned = value.replaceAll(RegExp(r'[^0-9.]'), '');
|
||||
final base = double.tryParse(cleaned) ?? 0;
|
||||
return base * multiplier;
|
||||
}
|
||||
|
||||
/// Format a value that is already in millions, e.g. 472.5 -> "472.5M".
|
||||
double _sumTradeEntries(List items) {
|
||||
return items.fold<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) {
|
||||
final match = RegExp(
|
||||
r'([0-9]+(?:\.[0-9]+)?)\s*([KMBT])?',
|
||||
caseSensitive: false,
|
||||
).firstMatch(value.replaceAll(',', ''));
|
||||
if (match == null) return 0;
|
||||
|
||||
final number = double.tryParse(match.group(1) ?? '') ?? 0;
|
||||
final suffix = (match.group(2) ?? '').toUpperCase();
|
||||
|
||||
switch (suffix) {
|
||||
case 'K':
|
||||
return number * 1000;
|
||||
case 'M':
|
||||
return number * 1000000;
|
||||
case 'B':
|
||||
return number * 1000000000;
|
||||
case 'T':
|
||||
return number * 1000000000000;
|
||||
default:
|
||||
return number;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Format a full numeric value into a readable K / M / B / T label.
|
||||
String formatTradeValue(double value) {
|
||||
return '${value.toStringAsFixed(1)}M';
|
||||
if (value >= 1000000000000) {
|
||||
return _wrapLtrTradeValue(
|
||||
'${_tradeNumberFormat.format(value / 1000000000000)} T',
|
||||
);
|
||||
}
|
||||
if (value >= 1000000000) {
|
||||
return _wrapLtrTradeValue(
|
||||
'${_tradeNumberFormat.format(value / 1000000000)} B',
|
||||
);
|
||||
}
|
||||
if (value >= 1000000) {
|
||||
return _wrapLtrTradeValue(
|
||||
'${_tradeNumberFormat.format(value / 1000000)} M',
|
||||
);
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return _wrapLtrTradeValue(
|
||||
'${_tradeNumberFormat.format(value / 1000)} K',
|
||||
);
|
||||
}
|
||||
return _wrapLtrTradeValue(_tradeNumberFormat.format(value));
|
||||
}
|
||||
|
||||
String formatTradeDisplayValue(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return '';
|
||||
final withoutCurrency = trimmed.toUpperCase().startsWith('AED ')
|
||||
? trimmed.substring(4).trim()
|
||||
: trimmed;
|
||||
final match =
|
||||
RegExp(r'^([0-9]+(?:\.[0-9]+)?)\s*([KMBT])$', caseSensitive: false)
|
||||
.firstMatch(withoutCurrency);
|
||||
if (match == null) return _wrapLtrTradeValue(withoutCurrency);
|
||||
final amount = double.tryParse(match.group(1) ?? '');
|
||||
final unit = (match.group(2) ?? '').toUpperCase();
|
||||
if (amount == null || unit.isEmpty) return _wrapLtrTradeValue(withoutCurrency);
|
||||
return _wrapLtrTradeValue('${_tradeNumberFormat.format(amount)} $unit');
|
||||
}
|
||||
|
||||
@override
|
||||
@ -697,16 +828,22 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
|
||||
final List items;
|
||||
if (widget.category == 'bilateral_trade') {
|
||||
return _buildBilateralTradeUI(
|
||||
widget.statistics![widget.category] as List);
|
||||
return _buildBilateralTradeUI(widget.statistics![widget.category] as List);
|
||||
} else {
|
||||
items = widget.statistics![widget.category] as List;
|
||||
}
|
||||
|
||||
final sortedItems = List.from(items);
|
||||
sortedItems.sort((a, b) {
|
||||
final orderA = int.tryParse(a['order_id']?.toString() ?? '') ?? 1 << 30;
|
||||
final orderB = int.tryParse(b['order_id']?.toString() ?? '') ?? 1 << 30;
|
||||
return orderA.compareTo(orderB);
|
||||
});
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: items.asMap().entries.map((entry) {
|
||||
children: sortedItems.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
|
||||
@ -715,9 +852,20 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
final rank = item['rank'];
|
||||
final rank_heading = item['rank_heading'];
|
||||
final bool isPositive = item['is_rank_positive'] == true;
|
||||
const aedTopics = {
|
||||
'Capital City',
|
||||
'Surface Area',
|
||||
'Population',
|
||||
'Male Population',
|
||||
'Female Population',
|
||||
'Income Level',
|
||||
};
|
||||
final currencyLabel = context.translate('(AED)', '(درهم)');
|
||||
final displayTopic =
|
||||
aedTopics.contains(topic) ? topic : '$topic $currencyLabel';
|
||||
// final isPositive = item['is_rank_positive'] ?? false;
|
||||
final icon = item['icon_url'];
|
||||
final bool isLast = index == items.length - 1;
|
||||
final bool isLast = index == sortedItems.length - 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@ -737,7 +885,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
topic,
|
||||
displayTopic,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
@ -756,7 +904,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
value.toString(),
|
||||
_wrapLtrTradeValue(value.toString()),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 28,
|
||||
@ -773,7 +921,11 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
rank.toString(),
|
||||
_wrapLtrTradeValue(
|
||||
rank_heading == 'Growth Rate'
|
||||
? '${rank.toString()} %'
|
||||
: rank.toString(),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
@ -894,18 +1046,9 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
final exportList = allTradeData['Export'] as List? ?? [];
|
||||
final reexportList = allTradeData['Reexport'] as List? ?? [];
|
||||
|
||||
importTotal = importList.fold<double>(
|
||||
0,
|
||||
(sum, item) => sum + parseTradeValue(item['value'] ?? ''),
|
||||
);
|
||||
exportTotal = exportList.fold<double>(
|
||||
0,
|
||||
(sum, item) => sum + parseTradeValue(item['value'] ?? ''),
|
||||
);
|
||||
reexportTotal = reexportList.fold<double>(
|
||||
0,
|
||||
(sum, item) => sum + parseTradeValue(item['value'] ?? ''),
|
||||
);
|
||||
importTotal = _sumTradeEntries(importList);
|
||||
exportTotal = _sumTradeEntries(exportList);
|
||||
reexportTotal = _sumTradeEntries(reexportList);
|
||||
} else {
|
||||
// Fallback: use the summary tradeData list if detailed data is missing.
|
||||
for (var item in tradeData) {
|
||||
@ -926,22 +1069,20 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
}
|
||||
}
|
||||
|
||||
final total = importTotal + exportTotal + reexportTotal;
|
||||
|
||||
final tabs = [
|
||||
{
|
||||
'key': 'import',
|
||||
'label': 'Total Import',
|
||||
'label': context.translate('Total Import', 'إجمالي الاستيراد'),
|
||||
'total': importTotal,
|
||||
},
|
||||
{
|
||||
'key': 'export',
|
||||
'label': 'Total Export',
|
||||
'label': context.translate('Total Export', 'إجمالي الصادرات'),
|
||||
'total': exportTotal,
|
||||
},
|
||||
{
|
||||
'key': 'reexport',
|
||||
'label': 'Total ReExport',
|
||||
'label': context.translate('Total ReExport', 'إجمالي إعادة التصدير'),
|
||||
'total': reexportTotal,
|
||||
},
|
||||
];
|
||||
@ -966,6 +1107,28 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
tradeLabel = selectedTradeType;
|
||||
}
|
||||
|
||||
String tradeYear = '';
|
||||
if (allTradeData.isNotEmpty) {
|
||||
for (final key in ['Import', 'Export', 'Reexport']) {
|
||||
final entries = allTradeData[key] as List?;
|
||||
if (entries == null || entries.isEmpty) continue;
|
||||
|
||||
for (final item in entries) {
|
||||
if (item is! Map) continue;
|
||||
final year = item['year']?.toString().trim() ?? '';
|
||||
if (year.isNotEmpty) {
|
||||
tradeYear = year;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tradeYear.isNotEmpty) break;
|
||||
}
|
||||
}
|
||||
|
||||
final bilateralTradeTitle = tradeYear.isNotEmpty
|
||||
? '${AppLocalizations.of(context)!.competitiveness_uae_bilateral_trade} ($tradeYear)'
|
||||
: AppLocalizations.of(context)!.competitiveness_uae_bilateral_trade;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@ -973,7 +1136,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
|
||||
/// 🔹 Title
|
||||
Text(
|
||||
AppLocalizations.of(context)!.competitiveness_uae_bilateral_trade,
|
||||
bilateralTradeTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
@ -995,7 +1158,9 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
color: isDarkTheme ? Colors.white : Color(0xFF000000)),
|
||||
),
|
||||
TextSpan(
|
||||
text: '\$${formatTradeValue(total)}',
|
||||
text: formatTradeValue(
|
||||
importTotal + exportTotal + reexportTotal,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
@ -1030,11 +1195,15 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
selectedTradeType = typeKey;
|
||||
|
||||
if (typeKey == 'import') {
|
||||
tradeItems = allTradeData['Import'] ?? [];
|
||||
tradeItems =
|
||||
_topTradeEntries(allTradeData['Import'] ?? []);
|
||||
} else if (typeKey == 'export') {
|
||||
tradeItems = allTradeData['Export'] ?? [];
|
||||
tradeItems =
|
||||
_topTradeEntries(allTradeData['Export'] ?? []);
|
||||
} else if (typeKey == 'reexport') {
|
||||
tradeItems = allTradeData['Reexport'] ?? [];
|
||||
tradeItems = _topTradeEntries(
|
||||
allTradeData['Reexport'] ?? [],
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -1101,10 +1270,10 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
|
||||
/// 🔹 Commodity Section Placeholder
|
||||
Text(
|
||||
context.translate(
|
||||
'Top 5 $tradeLabel Commodities',
|
||||
'أفضل 5 سلع $tradeLabel',
|
||||
),
|
||||
context.translate(
|
||||
'Top 5 $tradeLabel Commodities',
|
||||
'أفضل 5 سلع $tradeLabel',
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
@ -1162,7 +1331,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
width: 40,
|
||||
height: 40,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const SizedBox(width: 40, height: 40),
|
||||
const SizedBox(width: 40, height: 40),
|
||||
),
|
||||
),
|
||||
|
||||
@ -1192,7 +1361,8 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Text(
|
||||
'\$${formatTradeValue(parseTradeValue(item['value'] ?? ''))}',
|
||||
formatTradeDisplayValue(
|
||||
(item['value'] ?? '').toString()),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
@ -1205,7 +1375,7 @@ class _CategoryPopupContentState extends ConsumerState<CategoryPopupContent> {
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 15),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -174,28 +174,25 @@ class _ContactState extends ConsumerState<Contact> {
|
||||
color: isDarkTheme ? Colors.black : Colors.grey[200],
|
||||
width: screenWidth,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
child: Column(
|
||||
children: [
|
||||
buildContactCard(context,
|
||||
title: 'DUBAI - EMIRATES TOWER - 32ND FLOOR',
|
||||
titleAr: 'دبي - أبراج الإمارات - الطابق 32',
|
||||
title: 'Federal Competitiveness and Statistics Centre',
|
||||
titleAr: 'المركز الاتحادي للتنافسية والإحصاء',
|
||||
details: [
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.building,
|
||||
'text':
|
||||
'Office: Dubai - Emirates Tower - 32nd Floor',
|
||||
'textAr':
|
||||
'المقر الرئيسي: دبي - أبراج الإمارات - الطابق 32'
|
||||
'text': 'Office: PO Box 127000, 32nd Floor, Jumeirah Emirates Towers, Dubai, United Arab Emirates',
|
||||
'textAr': 'ص.ب. 127000، الطابق 32، أبراج الإمارات، دبي، الإمارات العربية المتحدة'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.phone,
|
||||
'text': 'Tel: 00971 4608 0000',
|
||||
'textAr': 'الهاتف: 0097146080000'
|
||||
'text': 'Tel: +971 4 608 0000',
|
||||
'textAr': 'الهاتف: +971 4 608 0000'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.fax,
|
||||
'text': 'Fax: 00971 4327 3535',
|
||||
'text': 'Fax: +971 4 327 3535',
|
||||
'textAr': 'فاكس: 0097143273535'
|
||||
},
|
||||
{
|
||||
@ -220,97 +217,97 @@ class _ContactState extends ConsumerState<Contact> {
|
||||
},
|
||||
],
|
||||
isDarkTheme: isDarkTheme),
|
||||
const SizedBox(height: 10),
|
||||
buildContactCard(
|
||||
context,
|
||||
title: 'MEDIA SECTION',
|
||||
titleAr: 'قسم الاعلام',
|
||||
details: [
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.building,
|
||||
'text': 'Office: Media Section',
|
||||
'textAr': 'المقر الرئيسي: قسم الاعلام'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.phone,
|
||||
'text': 'Tel: 00971 4608 0000',
|
||||
'textAr': 'الهاتف: 0097146080000'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.fax,
|
||||
'text': 'Fax: 00971 4327 3535',
|
||||
'textAr': 'فاكس: 0097143273535'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.map,
|
||||
'text': 'P.O.Box: 127000 Dubai',
|
||||
'textAr': 'ص.ب: 127000 دبي'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.at,
|
||||
'text': 'Email Address: GCO@fcsc.gov.ae',
|
||||
'textAr': 'البريد الإلكتروني: GCO@fcsc.gov.ae'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.map,
|
||||
'text': 'Latitude: 25.223926',
|
||||
'textAr': 'خط العرض: 25.223926'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.map,
|
||||
'text': 'Longitude: 55.350526',
|
||||
'textAr': 'خط الطول: 55.350526'
|
||||
},
|
||||
],
|
||||
isDarkTheme: isDarkTheme,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
buildContactCard(
|
||||
context,
|
||||
title: 'SGDS',
|
||||
titleAr: 'أهداف التنمية المستدامة',
|
||||
details: [
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.building,
|
||||
'text': 'Office: SDGs',
|
||||
'textAr': 'المقر الرئيسي: أهداف التنمية المستدامة'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.phone,
|
||||
'text': 'Tel: 00971 4608 0000',
|
||||
'textAr': 'الهاتف: 0097146080000'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.fax,
|
||||
'text': 'Fax: 00971 4327 3535',
|
||||
'textAr': 'فاكس : 0097143273535'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.map,
|
||||
'text': 'P.O.Box: 127000 Dubai',
|
||||
'textAr': 'ص.ب: 127000 دبي'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.at,
|
||||
'text': 'Email Address: sdgs@fcsc.gov.ae',
|
||||
'textAr': 'البريد الإلكتروني: sdgs@fcsc.gov.ae'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.map,
|
||||
'text': 'Latitude: 25.223926',
|
||||
'textAr': 'خط العرض: 25.223926'
|
||||
},
|
||||
{
|
||||
'icon': EnquiryAssetIconPath.map,
|
||||
'text': 'Longitude: 55.350526',
|
||||
'textAr': 'خط الطول: 55.350526'
|
||||
},
|
||||
],
|
||||
isDarkTheme: isDarkTheme,
|
||||
),
|
||||
// const SizedBox(height: 10),
|
||||
// buildContactCard(
|
||||
// context,
|
||||
// title: 'MEDIA SECTION',
|
||||
// titleAr: 'قسم الاعلام',
|
||||
// details: [
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.building,
|
||||
// 'text': 'Office: Media Section',
|
||||
// 'textAr': 'المقر الرئيسي: قسم الاعلام'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.phone,
|
||||
// 'text': 'Tel: +971 4 608 0000',
|
||||
// 'textAr': 'الهاتف: +971 4 608 0000'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.fax,
|
||||
// 'text': 'Fax: +971 4 327 3535',
|
||||
// 'textAr': 'فاكس: 0097143273535'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.map,
|
||||
// 'text': 'P.O.Box: 127000 Dubai',
|
||||
// 'textAr': 'ص.ب: 127000 دبي'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.at,
|
||||
// 'text': 'Email Address: GCO@fcsc.gov.ae',
|
||||
// 'textAr': 'البريد الإلكتروني: GCO@fcsc.gov.ae'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.map,
|
||||
// 'text': 'Latitude: 25.223926',
|
||||
// 'textAr': 'خط العرض: 25.223926'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.map,
|
||||
// 'text': 'Longitude: 55.350526',
|
||||
// 'textAr': 'خط الطول: 55.350526'
|
||||
// },
|
||||
// ],
|
||||
// isDarkTheme: isDarkTheme,
|
||||
// ),
|
||||
// const SizedBox(height: 10),
|
||||
// buildContactCard(
|
||||
// context,
|
||||
// title: 'SGDS',
|
||||
// titleAr: 'أهداف التنمية المستدامة',
|
||||
// details: [
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.building,
|
||||
// 'text': 'Office: SDGs',
|
||||
// 'textAr': 'المقر الرئيسي: أهداف التنمية المستدامة'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.phone,
|
||||
// 'text': 'Tel: +971 4 608 0000',
|
||||
// 'textAr': 'الهاتف: +971 4 608 0000'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.fax,
|
||||
// 'text': 'Fax: +971 4 327 3535',
|
||||
// 'textAr': 'فاكس : 0097143273535'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.map,
|
||||
// 'text': 'P.O.Box: 127000 Dubai',
|
||||
// 'textAr': 'ص.ب: 127000 دبي'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.at,
|
||||
// 'text': 'Email Address: sdgs@fcsc.gov.ae',
|
||||
// 'textAr': 'البريد الإلكتروني: sdgs@fcsc.gov.ae'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.map,
|
||||
// 'text': 'Latitude: 25.223926',
|
||||
// 'textAr': 'خط العرض: 25.223926'
|
||||
// },
|
||||
// {
|
||||
// 'icon': EnquiryAssetIconPath.map,
|
||||
// 'text': 'Longitude: 55.350526',
|
||||
// 'textAr': 'خط الطول: 55.350526'
|
||||
// },
|
||||
// ],
|
||||
// isDarkTheme: isDarkTheme,
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
),
|
||||
title: Text(context.translate('Contact Us', 'اتصل بنا')),
|
||||
),
|
||||
|
||||
@ -107,7 +107,7 @@ class _FAQPageState extends ConsumerState<FAQPage> {
|
||||
return BaseScaffold(
|
||||
dividerColor: Colors.grey[300],
|
||||
showDivider: true,
|
||||
showBackButton: true,
|
||||
showBackButton: false,
|
||||
// appbarColor: Colors.white,
|
||||
backgroundColor: isDarkTheme ? Colors.black : Colors.white,
|
||||
appbarColor: isDarkTheme ? Colors.black : Colors.white,
|
||||
|
||||
@ -1,238 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:uae_stat/config/theme/theme_provider.dart';
|
||||
import 'package:uae_stat/domain/use_cases/language.dart';
|
||||
import 'package:uae_stat/l10n/app_localizations.dart';
|
||||
import 'package:uae_stat/presentation/routes/drawer_routes/custom_drawer_routes.dart';
|
||||
import 'package:iconify_design/iconify_design.dart';
|
||||
|
||||
class Userguide extends ConsumerStatefulWidget {
|
||||
Userguide({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<Userguide> createState() => _UserguideState();
|
||||
}
|
||||
|
||||
class _UserguideState extends ConsumerState<Userguide> {
|
||||
late bool isDarkTheme;
|
||||
|
||||
final List<Map<String, dynamic>> guideList = [
|
||||
{
|
||||
'routePath': 'aboutApp',
|
||||
'color': Color(0xFF90B0D5),
|
||||
'text': 'Getting Started',
|
||||
'icon': 'cbi:start-tv',
|
||||
'text-ar': 'البدء'
|
||||
},
|
||||
{
|
||||
'routePath': 'features',
|
||||
'color': Color(0xFFAA8E83),
|
||||
'text': 'Key Features',
|
||||
'icon': 'pajamas:issue-type-feature',
|
||||
'text-ar': 'الميزات الرئيسية'
|
||||
},
|
||||
{
|
||||
'routePath': 'faq',
|
||||
'color': Color(0xFF7296BE),
|
||||
'text': 'FAQs',
|
||||
'icon': 'mdi:faq',
|
||||
'text-ar': 'لأسئلة الشائعة'
|
||||
},
|
||||
];
|
||||
|
||||
late ThemeMode currentTheme;
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildDynamicWidget(dynamic icons, Color color) {
|
||||
if (icons is IconData) {
|
||||
return Icon(
|
||||
icons,
|
||||
size: 38,
|
||||
color: color, // Apply color dynamically
|
||||
);
|
||||
} else if (icons is String) {
|
||||
return Text(
|
||||
icons,
|
||||
style: TextStyle(
|
||||
fontFamily: context.translate(
|
||||
'Roboto',
|
||||
'NotoKufi',
|
||||
),
|
||||
// fontFamily: 'Handel',
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
letterSpacing: 1.6,
|
||||
color: color, // Apply color dynamically
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
'Unsupported type',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontFamily: context.translate(
|
||||
'Roboto',
|
||||
'NotoKufi',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentTheme = ref.watch(themeProvider);
|
||||
isDarkTheme = currentTheme == ThemeMode.dark ||
|
||||
(currentTheme == ThemeMode.system &&
|
||||
MediaQuery.of(context).platformBrightness == Brightness.dark);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) return;
|
||||
context.go('/myhomepage');
|
||||
},
|
||||
child: BaseScaffold(
|
||||
showDivider: true,
|
||||
bottomColor: isDarkTheme ? Colors.black : Colors.white,
|
||||
// dividerColor: isDarkTheme ? Color(0xFF111111) : Colors.grey[300],
|
||||
dividerColor: isDarkTheme ? Colors.grey : Colors.grey[300],
|
||||
appbarColor: isDarkTheme ? Colors.black: Colors.white,
|
||||
title: Text(AppLocalizations.of(context)!.guide_title,
|
||||
style: TextStyle(
|
||||
color: isDarkTheme ? Colors.white : Color(0xFF414042))),
|
||||
body: Container(
|
||||
color: isDarkTheme ? Colors.black : Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
child: GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 10.0,
|
||||
mainAxisSpacing: 10.0,
|
||||
childAspectRatio: 1.7,
|
||||
// mainAxisExtent:MediaQuery.of(context).size.height* 0.15 ,
|
||||
),
|
||||
itemCount: guideList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return HoverContainer(
|
||||
iconName: guideList[index]['icon'],
|
||||
title: context.translate(
|
||||
guideList[index]['text'], guideList[index]['text-ar']),
|
||||
routePath: guideList[index]['routePath'],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HoverContainer extends ConsumerStatefulWidget {
|
||||
final String iconName;
|
||||
final String title;
|
||||
final String routePath;
|
||||
|
||||
const HoverContainer(
|
||||
{Key? key,
|
||||
required this.iconName,
|
||||
required this.title,
|
||||
required this.routePath})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
ConsumerState<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(
|
||||
color: Colors.white, // Text color
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: context.translate(
|
||||
'Roboto',
|
||||
'NotoKufi',
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:uae_stat/config/theme/theme_provider.dart';
|
||||
import 'package:uae_stat/domain/use_cases/language.dart';
|
||||
import 'package:uae_stat/l10n/app_localizations.dart';
|
||||
import 'package:uae_stat/presentation/routes/drawer_routes/custom_drawer_routes.dart';
|
||||
|
||||
class Userguide extends ConsumerStatefulWidget {
|
||||
Userguide({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<Userguide> createState() => _UserguideState();
|
||||
}
|
||||
|
||||
class _UserguideState extends ConsumerState<Userguide> {
|
||||
late bool isDarkTheme;
|
||||
|
||||
final List<Map<String, String>> guideItems = const [
|
||||
{
|
||||
'title': 'About FCSC',
|
||||
'titleAr': 'نبذة عن المركز الاتحادي للتنافسية والإحصاء',
|
||||
'path': '/user-guide/aboutApp/aboutFCSC',
|
||||
},
|
||||
{
|
||||
'title': 'Getting Started',
|
||||
'titleAr': 'البدء',
|
||||
'path': '/user-guide/aboutApp/getStarted',
|
||||
},
|
||||
{
|
||||
'title': 'App Features',
|
||||
'titleAr': 'الميزات الرئيسية',
|
||||
'path': '/user-guide/features/appFeatures',
|
||||
},
|
||||
{
|
||||
'title': 'Who can use the app?',
|
||||
'titleAr': 'من يمكنه استخدام التطبيق؟',
|
||||
'path': '/user-guide/features/whoUseApp',
|
||||
},
|
||||
{
|
||||
'title': 'Purpose of the app',
|
||||
'titleAr': 'الغرض من التطبيق',
|
||||
'path': '/user-guide/features/purpose',
|
||||
},
|
||||
{
|
||||
'title': 'How to use the app',
|
||||
'titleAr': 'كيفية استخدام التطبيق',
|
||||
'path': '/user-guide/features/howUseApp',
|
||||
},
|
||||
{
|
||||
'title': 'Stay updated',
|
||||
'titleAr': 'ابق على اطلاع',
|
||||
'path': '/user-guide/features/stayUpdated',
|
||||
},
|
||||
{
|
||||
'title': 'How to change my password',
|
||||
'titleAr': 'كيفية تغيير كلمة المرور الخاصة بي',
|
||||
'path': '/user-guide/features/changeMyPassword',
|
||||
},
|
||||
{
|
||||
'title': 'How to edit my profile',
|
||||
'titleAr': 'كيفية تعديل ملفي الشخصي',
|
||||
'path': '/user-guide/features/editMyProfile',
|
||||
},
|
||||
];
|
||||
|
||||
late ThemeMode currentTheme;
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentTheme = ref.watch(themeProvider);
|
||||
isDarkTheme = currentTheme == ThemeMode.dark ||
|
||||
(currentTheme == ThemeMode.system &&
|
||||
MediaQuery.of(context).platformBrightness == Brightness.dark);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) return;
|
||||
context.go('/myhomepage');
|
||||
},
|
||||
child: BaseScaffold(
|
||||
showDivider: true,
|
||||
bottomColor: isDarkTheme ? Colors.black : Colors.white,
|
||||
// dividerColor: isDarkTheme ? Color(0xFF111111) : Colors.grey[300],
|
||||
dividerColor: isDarkTheme ? Colors.grey : Colors.grey[300],
|
||||
appbarColor: isDarkTheme ? Colors.black: Colors.white,
|
||||
title: Text(AppLocalizations.of(context)!.guide_title,
|
||||
style: TextStyle(
|
||||
color: isDarkTheme ? Colors.white : Color(0xFF414042))),
|
||||
body: Container(
|
||||
color: isDarkTheme ? Colors.black : Colors.white,
|
||||
child: ListView.separated(
|
||||
itemCount: guideItems.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
height: 1,
|
||||
color: isDarkTheme ? Colors.grey : const Color(0xFFDEDEDE),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = guideItems[index];
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
title: Text(
|
||||
context.translate(item['title']!, item['titleAr']!),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDarkTheme ? Colors.white : const Color(0xFF414042),
|
||||
fontFamily: context.translate('Roboto', 'NotoKufi'),
|
||||
),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.keyboard_arrow_right,
|
||||
color: isDarkTheme ? const Color(0xFF898C81) : const Color(0xFF898C81),
|
||||
),
|
||||
onTap: () => context.push(item['path']!),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,14 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:tutorial_coach_mark/tutorial_coach_mark.dart';
|
||||
import 'package:uae_stat/config/injector.dart';
|
||||
@ -21,6 +23,7 @@ import 'package:uae_stat/infrastructure/data/p_auth_repo.dart';
|
||||
import 'package:uae_stat/infrastructure/services/img_asset_paths/icons/bottom_bar_asset_icon_path.dart';
|
||||
import 'package:uae_stat/infrastructure/services/pocketbase_service.dart';
|
||||
import 'package:uae_stat/l10n/app_localizations.dart';
|
||||
import 'package:uae_stat/presentation/routes/auth_routes/oauth_webview.dart';
|
||||
|
||||
import 'package:uae_stat/presentation/components/indicators/locale_provider.dart';
|
||||
import 'package:uae_stat/presentation/components/my_toggle.dart';
|
||||
@ -956,6 +959,22 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
||||
MediaQuery.of(context).platformBrightness == Brightness.dark);
|
||||
|
||||
final String currentRoute1 = ModalRoute.of(context)?.settings.name ?? '';
|
||||
final hideBottomNavActiveColor = currentRoute == '/feedback' ||
|
||||
currentRoute == '/notification' ||
|
||||
currentRoute == '/notification_details' ||
|
||||
currentRoute == '/user-guide' ||
|
||||
currentRoute.startsWith('/user-guide/') ||
|
||||
currentRoute == '/aboutApp' ||
|
||||
currentRoute == '/faq' ||
|
||||
currentRoute == '/editProfile' ||
|
||||
currentRoute == '/bookmark' ||
|
||||
currentRoute == '/manageuser' ||
|
||||
currentRoute == '/contact' ||
|
||||
currentRoute == '/chartScreen/:dataSets';
|
||||
final bottomNavActiveColor = hideBottomNavActiveColor
|
||||
? const Color(0xFF989898)
|
||||
: const Color(0xFFAA8E83);
|
||||
final bottomNavInactiveColor = const Color(0xFF989898);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: widget.backgroundColor,
|
||||
@ -1416,6 +1435,21 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
||||
),
|
||||
onTap: () => _navigateTo(context, '/user-guide'),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.quiz_outlined,
|
||||
color: isDarkTheme ? Color(0xFFFFFFFF) : null),
|
||||
title: Text(
|
||||
context.translate('FAQs', 'الأسئلة الشائعة'),
|
||||
style: TextStyle(
|
||||
color: isDarkTheme ? Color(0xFFAAAAAA) : null,
|
||||
fontFamily: context.translate(
|
||||
'Roboto',
|
||||
'NotoKufi',
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () => _navigateTo(context, '/faq'),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.mail_outlined,
|
||||
color: isDarkTheme ? Color(0xFFFFFFFF) : null),
|
||||
@ -1587,8 +1621,8 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
||||
BottomBarAssetIconPath.home,
|
||||
fit: BoxFit.contain,
|
||||
color: _getSelectedIndex(currentRoute) == 0
|
||||
? Color(0xFFAA8E83)
|
||||
: Color(0xFF989898),
|
||||
? bottomNavActiveColor
|
||||
: bottomNavInactiveColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1606,8 +1640,8 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
||||
BottomBarAssetIconPath.uaeMap,
|
||||
fit: BoxFit.contain,
|
||||
color: _getSelectedIndex(currentRoute) == 1
|
||||
? Color(0xFFAA8E83)
|
||||
: Color(0xFF989898),
|
||||
? bottomNavActiveColor
|
||||
: bottomNavInactiveColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1624,8 +1658,8 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
||||
BottomBarAssetIconPath.ranking,
|
||||
fit: BoxFit.contain,
|
||||
color: _getSelectedIndex(currentRoute) == 2
|
||||
? Color(0xFFAA8E83)
|
||||
: Color(0xFF989898),
|
||||
? bottomNavActiveColor
|
||||
: bottomNavInactiveColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1642,8 +1676,8 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
||||
BottomBarAssetIconPath.globe,
|
||||
fit: BoxFit.contain,
|
||||
color: _getSelectedIndex(currentRoute) == 3
|
||||
? Color(0xFFAA8E83)
|
||||
: Color(0xFF989898),
|
||||
? bottomNavActiveColor
|
||||
: bottomNavInactiveColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -1652,8 +1686,8 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
||||
),
|
||||
],
|
||||
key: bottomNavKey,
|
||||
selectedItemColor: Color(0xFFAA8E83),
|
||||
unselectedItemColor: Color(0xFF989898),
|
||||
selectedItemColor: bottomNavActiveColor,
|
||||
unselectedItemColor: bottomNavInactiveColor,
|
||||
selectedLabelStyle: TextStyle(
|
||||
fontFamily: context.translate(
|
||||
'Roboto',
|
||||
@ -1812,11 +1846,61 @@ class _BaseScaffoldState extends ConsumerState<BaseScaffold> {
|
||||
|
||||
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 {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// await _logoutFromUaePassIfNeeded(context);
|
||||
await _authRepo.logout();
|
||||
prefs.clear();
|
||||
context.go('/login'); // Redirect to login after logout
|
||||
await prefs.clear();
|
||||
if (context.mounted) {
|
||||
context.go('/login'); // Redirect to login after logout
|
||||
}
|
||||
}
|
||||
|
||||
void _showLogoutConfirmationDialog(BuildContext context, isDarkTheme) {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
name: uae_stat
|
||||
description: "View statistics about the UAE from the Federal Center for Statistics and Competitiveness."
|
||||
publish_to: "none"
|
||||
#version: 1.2.18+26
|
||||
version: 1.2.16+24
|
||||
#version: 1.2.26+34
|
||||
version: 1.2.24+32
|
||||
#version: 1.0.16+17
|
||||
#version: 1.0.6+6
|
||||
|
||||
@ -173,6 +173,7 @@ flutter:
|
||||
- assets/icons/uae_numbers/ShareDark.png
|
||||
- assets/icons/uae_numbers/ShareLight.png
|
||||
- assets/icons/misc/successMsg.png
|
||||
- assets/uae_pass_button/
|
||||
|
||||
fonts:
|
||||
- family: Segoe
|
||||
|
||||