From 0466341f6245bb398bb7c2c3d5b1bc64986641e9 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Thu, 26 Mar 2026 11:53:36 +0530 Subject: [PATCH] feat: Implement Twitter (X) login using OAuth 2.0 PKCE flow via webview. --- ios/Podfile.lock | 6 - lib/core/services/twitter_auth_service.dart | 107 +++++++++++++ .../presentation/providers/auth_provider.dart | 37 +---- .../screens/twitter_login_screen.dart | 144 ++++++++++++++++++ .../widgets/social_login_buttons.dart | 13 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 10 +- pubspec.yaml | 2 +- 8 files changed, 272 insertions(+), 49 deletions(-) create mode 100644 lib/core/services/twitter_auth_service.dart create mode 100644 lib/features/auth/presentation/screens/twitter_login_screen.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5a76497..98d32d6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -124,8 +124,6 @@ PODS: - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - - twitter_login (0.0.1): - - Flutter - url_launcher_ios (0.0.1): - Flutter - webview_flutter_wkwebview (0.0.1): @@ -146,7 +144,6 @@ DEPENDENCIES: - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - - twitter_login (from `.symlinks/plugins/twitter_login/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) @@ -198,8 +195,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite_darwin: :path: ".symlinks/plugins/sqflite_darwin/darwin" - twitter_login: - :path: ".symlinks/plugins/twitter_login/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" webview_flutter_wkwebview: @@ -237,7 +232,6 @@ SPEC CHECKSUMS: PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - twitter_login: 2794db69b7640681171b17b3c2c84ad9dfb4a57f url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 diff --git a/lib/core/services/twitter_auth_service.dart b/lib/core/services/twitter_auth_service.dart new file mode 100644 index 0000000..06924a7 --- /dev/null +++ b/lib/core/services/twitter_auth_service.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; + +/// Twitter OAuth 2.0 PKCE flow for mobile. +class TwitterAuthService { + static const _clientId = 'eWEzaDJ0al9lX1diOGY0ejRYeXM6MTpjaQ'; + // Must match the callback URL configured in Twitter Developer Portal + static const _redirectUri = 'https://dev.re-quest.com/auth/callback/twitter'; + static const _scope = 'tweet.read users.read offline.access'; + + late final String _codeVerifier; + late final String _codeChallenge; + late final String _stateParam; + + TwitterAuthService() { + _codeVerifier = _generateCodeVerifier(); + _codeChallenge = _generateCodeChallenge(_codeVerifier); + _stateParam = _generateRandomString(32); + } + + /// Build the Twitter authorization URL to open in a webview. + String get authorizationUrl { + final params = { + 'response_type': 'code', + 'client_id': _clientId, + 'redirect_uri': _redirectUri, + 'scope': _scope, + 'state': _stateParam, + 'code_challenge': _codeChallenge, + 'code_challenge_method': 'S256', + }; + final query = params.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + return 'https://twitter.com/i/oauth2/authorize?$query'; + } + + /// Check if a URL is the redirect callback and extract the auth code. + String? extractCodeFromUrl(String url) { + if (!url.startsWith(_redirectUri)) return null; + final uri = Uri.parse(url); + final state = uri.queryParameters['state']; + if (state != _stateParam) return null; + return uri.queryParameters['code']; + } + + /// Exchange the auth code for tokens and fetch user info. + Future?> exchangeCodeForUser(String code) async { + final dio = Dio(); + + // Exchange code for access token + final tokenResponse = await dio.post( + 'https://api.twitter.com/2/oauth2/token', + options: Options( + contentType: 'application/x-www-form-urlencoded', + headers: { + 'Authorization': 'Basic ${base64Encode(utf8.encode('$_clientId:'))}', + }, + ), + data: { + 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': _redirectUri, + 'code_verifier': _codeVerifier, + }, + ); + + final accessToken = tokenResponse.data['access_token'] as String?; + if (accessToken == null) return null; + + // Fetch user info + final userResponse = await dio.get( + 'https://api.twitter.com/2/users/me', + queryParameters: {'user.fields': 'id,name,username,profile_image_url'}, + options: Options( + headers: {'Authorization': 'Bearer $accessToken'}, + ), + ); + + final userData = userResponse.data['data'] as Map?; + if (userData == null) return null; + + return { + 'id': userData['id'], + 'name': userData['name'], + 'username': userData['username'], + 'avatar': userData['profile_image_url'], + }; + } + + String _generateRandomString(int length) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + final random = Random.secure(); + return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join(); + } + + String _generateCodeVerifier() => _generateRandomString(64); + + String _generateCodeChallenge(String verifier) { + final bytes = utf8.encode(verifier); + final digest = sha256.convert(bytes); + return base64Url.encode(digest.bytes).replaceAll('=', ''); + } +} diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index 8cd1e42..f8980b9 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -10,7 +10,6 @@ import 'package:real_estate_mobile/features/auth/data/auth_repository.dart'; import 'package:real_estate_mobile/features/auth/data/models/register_request.dart'; import 'package:real_estate_mobile/features/auth/data/models/user_model.dart'; import 'package:real_estate_mobile/features/messaging/data/socket_service.dart'; -import 'package:twitter_login/twitter_login.dart'; // Auth state enum AuthStatus { initial, loading, authenticated, error } @@ -291,7 +290,8 @@ class AuthNotifier extends StateNotifier { } } - Future signInWithTwitter() async { + /// Complete Twitter/X login after webview returns user data. + Future completeTwitterLogin(Map twitterUser) async { state = state.copyWith( status: AuthStatus.loading, errorMessage: null, @@ -299,35 +299,12 @@ class AuthNotifier extends StateNotifier { ); try { - final twitterLogin = TwitterLogin( - apiKey: 'eWEzaDJ0al9lX1diOGY0ejRYeXM6MTpjaQ', - apiSecretKey: 'HadGMSRKKswfXOajbmggrnmLAn9cxJsl8GtFxGHqjX-CJ0A2Tu', - redirectURI: 'request://', - ); - - final authResult = await twitterLogin.login(); - - if (authResult.status == TwitterLoginStatus.cancelledByUser) { - state = state.copyWith(status: AuthStatus.initial); - return; - } - - if (authResult.status != TwitterLoginStatus.loggedIn) { - state = state.copyWith( - status: AuthStatus.error, - errorMessage: authResult.errorMessage ?? 'Twitter login failed', - ); - return; - } - - final twitterUser = authResult.user!; - final user = await _repository.socialAuth( provider: 'twitter', - providerId: twitterUser.id.toString(), - email: '${twitterUser.screenName}@twitter.placeholder', - name: twitterUser.name, - avatar: twitterUser.thumbnailImage, + providerId: twitterUser['id'] as String, + email: '${twitterUser['username']}@x.com', + name: twitterUser['name'] as String?, + avatar: twitterUser['avatar'] as String?, ); state = state.copyWith( @@ -345,7 +322,7 @@ class AuthNotifier extends StateNotifier { debugPrint('Twitter sign-in error: $e'); state = state.copyWith( status: AuthStatus.error, - errorMessage: 'Twitter sign-in failed. Please try again.', + errorMessage: 'X/Twitter sign-in failed. Please try again.', ); } } diff --git a/lib/features/auth/presentation/screens/twitter_login_screen.dart b/lib/features/auth/presentation/screens/twitter_login_screen.dart new file mode 100644 index 0000000..866499f --- /dev/null +++ b/lib/features/auth/presentation/screens/twitter_login_screen.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:real_estate_mobile/core/constants/app_colors.dart'; +import 'package:real_estate_mobile/core/services/twitter_auth_service.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// Opens a webview for Twitter OAuth 2.0 PKCE login. +/// Returns the user data map on success, or null on cancel/failure. +class TwitterLoginScreen extends StatefulWidget { + const TwitterLoginScreen({super.key}); + + @override + State createState() => _TwitterLoginScreenState(); +} + +class _TwitterLoginScreenState extends State { + late final TwitterAuthService _authService; + late final WebViewController _webViewController; + bool _isExchanging = false; + + @override + void initState() { + super.initState(); + _authService = TwitterAuthService(); + _webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setUserAgent( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 ' + 'Mobile/15E148 Safari/604.1', + ) + ..setNavigationDelegate(NavigationDelegate( + onPageStarted: (url) { + final code = _authService.extractCodeFromUrl(url); + if (code != null) { + _handleCode(code); + } + }, + onNavigationRequest: (request) { + final code = _authService.extractCodeFromUrl(request.url); + if (code != null) { + _handleCode(code); + return NavigationDecision.prevent; + } + // User cancelled — Twitter redirects to main page or login + final url = request.url; + if (url == 'https://twitter.com/' || + url == 'https://x.com/' || + url.startsWith('https://twitter.com/home') || + url.startsWith('https://x.com/home') || + url.startsWith('https://twitter.com/i/flow/login') || + url.startsWith('https://x.com/i/flow/login')) { + Navigator.pop(context, null); + return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + )) + ..loadRequest(Uri.parse(_authService.authorizationUrl)); + } + + Future _handleCode(String code) async { + if (_isExchanging) return; + setState(() => _isExchanging = true); + + try { + final userData = await _authService.exchangeCodeForUser(code); + if (mounted) Navigator.pop(context, userData); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('X login failed. Please try again.')), + ); + Navigator.pop(context, null); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // Minimal top bar with back button + SizedBox( + height: 44, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, + size: 18, color: AppColors.primaryDark), + onPressed: () => Navigator.pop(context, null), + ), + const Expanded( + child: Text( + 'Sign in with X', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(width: 48), + ], + ), + ), + // Webview + Expanded( + child: Stack( + children: [ + WebViewWidget(controller: _webViewController), + if (_isExchanging) + Container( + color: Colors.white.withValues(alpha: 0.8), + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: AppColors.accentOrange), + SizedBox(height: 16), + Text('Signing in...', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + color: AppColors.primaryDark, + )), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/widgets/social_login_buttons.dart b/lib/features/auth/presentation/widgets/social_login_buttons.dart index b2ca9a1..ab814f5 100644 --- a/lib/features/auth/presentation/widgets/social_login_buttons.dart +++ b/lib/features/auth/presentation/widgets/social_login_buttons.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:real_estate_mobile/core/constants/app_colors.dart'; import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:real_estate_mobile/features/auth/presentation/screens/twitter_login_screen.dart'; class SocialLoginButtons extends ConsumerWidget { const SocialLoginButtons({super.key}); @@ -17,6 +18,16 @@ class SocialLoginButtons extends ConsumerWidget { ); } + Future _handleTwitterLogin(BuildContext context, WidgetRef ref) async { + final result = await Navigator.push?>( + context, + MaterialPageRoute(builder: (_) => const TwitterLoginScreen()), + ); + if (result != null) { + ref.read(authProvider.notifier).completeTwitterLogin(result); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final authState = ref.watch(authProvider); @@ -51,7 +62,7 @@ class SocialLoginButtons extends ConsumerWidget { _SocialButton( onTap: isLoading ? null - : () => ref.read(authProvider.notifier).signInWithTwitter(), + : () => _handleTwitterLogin(context, ref), child: SvgPicture.asset( 'assets/icons/x.svg', width: 24, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d41417a..c75bcbb 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -17,7 +17,6 @@ import package_info_plus import path_provider_foundation import shared_preferences_foundation import sqflite_darwin -import twitter_login import url_launcher_macos import webview_flutter_wkwebview @@ -34,7 +33,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) - TwitterLoginPlugin.register(with: registry.registrar(forPlugin: "TwitterLoginPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index bbc0089..0700d2b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -218,7 +218,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -1349,14 +1349,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - twitter_login: - dependency: "direct main" - description: - name: twitter_login - sha256: "31ff9db2e37eda878b876a4ce6d1525f51d34b6cd9de9aa185b07027a23ab95b" - url: "https://pub.dev" - source: hosted - version: "4.4.2" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2661476..6cfd048 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,7 +48,7 @@ dependencies: # Social Login google_sign_in: ^6.2.2 flutter_facebook_auth: ^7.1.1 - twitter_login: ^4.4.2 + crypto: ^3.0.6 # Pin to avoid objective_c crash on iOS 26 simulator path_provider_foundation: 2.4.0