new design

This commit is contained in:
Surendiran 2026-05-27 10:19:00 +05:30
parent 10f8ab2db0
commit 3e1ca46722
43 changed files with 5846 additions and 1718 deletions

View File

@ -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")

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -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";

View File

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

View File

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

View File

@ -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;

View File

@ -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=

View 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;
}

View File

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

View File

@ -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';

View File

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

View File

@ -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.

View File

@ -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 => 'المجموع ';

View File

@ -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 ';

View File

@ -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": "ملف تعريف الدولة",

View File

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

View File

@ -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)!

View File

@ -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'] ?? '';
});

View File

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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,

View File

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

View File

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

View File

@ -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', 'اتصل بنا')),
),

View File

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

View File

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

View File

@ -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) {

View File

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