refactor: Update Twitter OAuth 2.0 PKCE flow for dynamic redirect URI and public client authentication, replace SVG back button with a native icon, and enhance Twitter login error reporting.

This commit is contained in:
pradeepkumar
2026-03-26 15:05:06 +05:30
parent 0466341f62
commit ba87e3b091
7 changed files with 135 additions and 54 deletions

View File

@@ -3,14 +3,20 @@ import 'dart:math';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:real_estate_mobile/config/app_config.dart';
/// Twitter OAuth 2.0 PKCE flow for mobile. /// Twitter OAuth 2.0 PKCE flow for mobile.
class TwitterAuthService { class TwitterAuthService {
static const _clientId = 'eWEzaDJ0al9lX1diOGY0ejRYeXM6MTpjaQ'; 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'; static const _scope = 'tweet.read users.read offline.access';
/// Derive redirect URI from API base URL (matches next-auth callback)
static String get _redirectUri {
final apiUrl = AppConfig.apiBaseUrl; // e.g. https://dev.re-quest.com/api/v1
final base = apiUrl.replaceAll(RegExp(r'/api/v1/?$'), '');
return '$base/api/auth/callback/twitter';
}
late final String _codeVerifier; late final String _codeVerifier;
late final String _codeChallenge; late final String _codeChallenge;
late final String _stateParam; late final String _stateParam;
@@ -51,21 +57,20 @@ class TwitterAuthService {
Future<Map<String, dynamic>?> exchangeCodeForUser(String code) async { Future<Map<String, dynamic>?> exchangeCodeForUser(String code) async {
final dio = Dio(); final dio = Dio();
// Exchange code for access token // Exchange code for access token (PKCE public client — client_id in body)
final tokenBody =
'code=${Uri.encodeComponent(code)}'
'&grant_type=authorization_code'
'&client_id=${Uri.encodeComponent(_clientId)}'
'&redirect_uri=${Uri.encodeComponent(_redirectUri)}'
'&code_verifier=${Uri.encodeComponent(_codeVerifier)}';
final tokenResponse = await dio.post( final tokenResponse = await dio.post(
'https://api.twitter.com/2/oauth2/token', 'https://api.twitter.com/2/oauth2/token',
options: Options( options: Options(
contentType: 'application/x-www-form-urlencoded', contentType: 'application/x-www-form-urlencoded',
headers: {
'Authorization': 'Basic ${base64Encode(utf8.encode('$_clientId:'))}',
},
), ),
data: { data: tokenBody,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': _redirectUri,
'code_verifier': _codeVerifier,
},
); );
final accessToken = tokenResponse.data['access_token'] as String?; final accessToken = tokenResponse.data['access_token'] as String?;

View File

@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:real_estate_mobile/core/constants/app_colors.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/providers/auth_provider.dart';
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart'; import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
/// Determines which tab is active based on the current route. /// Determines which tab is active based on the current route.
int _currentTabIndex(BuildContext context) { int _currentTabIndex(BuildContext context) {
@@ -78,19 +79,26 @@ class AppBottomNavBar extends ConsumerWidget {
} }
}, },
), ),
_NavItem( Builder(builder: (context) {
svgPath: 'assets/icons/nav_messages_icon.svg', final authState = ref.watch(authProvider);
fallbackIcon: Icons.chat_bubble, final isAuth = authState.status == AuthStatus.authenticated;
isSelected: selectedIndex == 2, final msgUnread = isAuth
onTap: () { ? ref.watch(messagingProvider).unreadCount
final authState = ref.read(authProvider); : 0;
if (authState.status != AuthStatus.authenticated) { return _NavItem(
context.push('/login'); svgPath: 'assets/icons/nav_messages_icon.svg',
return; fallbackIcon: Icons.chat_bubble,
} isSelected: selectedIndex == 2,
context.go('/messages'); badgeCount: msgUnread,
}, onTap: () {
), if (!isAuth) {
context.push('/login');
return;
}
context.go('/messages');
},
);
}),
_NavItem( _NavItem(
svgPath: 'assets/icons/nav_profile_icon.svg', svgPath: 'assets/icons/nav_profile_icon.svg',
fallbackIcon: Icons.person_outline, fallbackIcon: Icons.person_outline,
@@ -117,12 +125,14 @@ class _NavItem extends StatelessWidget {
final IconData fallbackIcon; final IconData fallbackIcon;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
final int badgeCount;
const _NavItem({ const _NavItem({
required this.svgPath, required this.svgPath,
required this.fallbackIcon, required this.fallbackIcon,
required this.isSelected, required this.isSelected,
required this.onTap, required this.onTap,
this.badgeCount = 0,
}); });
@override @override
@@ -132,20 +142,50 @@ class _NavItem extends StatelessWidget {
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SvgPicture.asset( child: Stack(
svgPath, clipBehavior: Clip.none,
width: 24, children: [
height: 24, SvgPicture.asset(
colorFilter: isSelected svgPath,
? const ColorFilter.mode( width: 24,
AppColors.primaryDark, BlendMode.srcIn) height: 24,
: const ColorFilter.mode( colorFilter: isSelected
AppColors.accentOrange, BlendMode.srcIn), ? const ColorFilter.mode(
placeholderBuilder: (_) => Icon( AppColors.primaryDark, BlendMode.srcIn)
fallbackIcon, : const ColorFilter.mode(
size: 24, AppColors.accentOrange, BlendMode.srcIn),
color: isSelected ? AppColors.primaryDark : AppColors.accentOrange, placeholderBuilder: (_) => Icon(
), fallbackIcon,
size: 24,
color:
isSelected ? AppColors.primaryDark : AppColors.accentOrange,
),
),
if (badgeCount > 0)
Positioned(
top: -6,
right: -8,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
constraints: const BoxConstraints(minWidth: 16),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(8),
),
child: Text(
badgeCount > 99 ? '99+' : '$badgeCount',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 10,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
],
), ),
), ),
); );

View File

@@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart'; import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart'; import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
@@ -343,17 +342,31 @@ class _AgentEditProfileScreenState
return Column( return Column(
children: [ children: [
// Header with circular back button (SVG) matching Figma // Header with back button
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row( child: Row(
children: [ children: [
GestureDetector( GestureDetector(
onTap: () => context.pop(), onTap: () {
child: SvgPicture.asset( if (Navigator.of(context).canPop()) {
'assets/icons/back_circle_icon.svg', Navigator.of(context).pop();
width: 32, } else {
height: 32, context.go('/home');
}
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(
Icons.arrow_back_ios_new_rounded,
color: AppColors.primaryDark,
size: 18,
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),

View File

@@ -66,9 +66,10 @@ class _TwitterLoginScreenState extends State<TwitterLoginScreen> {
final userData = await _authService.exchangeCodeForUser(code); final userData = await _authService.exchangeCodeForUser(code);
if (mounted) Navigator.pop(context, userData); if (mounted) Navigator.pop(context, userData);
} catch (e) { } catch (e) {
debugPrint('Twitter token exchange error: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('X login failed. Please try again.')), SnackBar(content: Text('X login failed: ${e.toString().length > 100 ? e.toString().substring(0, 100) : e}')),
); );
Navigator.pop(context, null); Navigator.pop(context, null);
} }

View File

@@ -75,6 +75,7 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
MessagingNotifier(this._repository, this._ref) MessagingNotifier(this._repository, this._ref)
: super(const MessagingState()) { : super(const MessagingState()) {
_initSocketListeners(); _initSocketListeners();
loadUnreadCount();
} }
String? get _currentUserId => _ref.read(authProvider).user?.id; String? get _currentUserId => _ref.read(authProvider).user?.id;
@@ -132,9 +133,15 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
return c; return c;
}).toList(); }).toList();
// Recalculate total unread count
final totalUnread = updatedConversations.fold<int>(
0, (sum, c) => sum + c.unreadCount,
);
state = state.copyWith( state = state.copyWith(
messages: updatedMessages, messages: updatedMessages,
conversations: updatedConversations, conversations: updatedConversations,
unreadCount: totalUnread,
); );
})); }));
@@ -575,9 +582,13 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
..remove(conversationId); ..remove(conversationId);
final updatedConversations = final updatedConversations =
state.conversations.where((c) => c.id != conversationId).toList(); state.conversations.where((c) => c.id != conversationId).toList();
final totalUnread = updatedConversations.fold<int>(
0, (sum, c) => sum + c.unreadCount,
);
state = state.copyWith( state = state.copyWith(
messages: updatedMessages, messages: updatedMessages,
conversations: updatedConversations, conversations: updatedConversations,
unreadCount: totalUnread,
clearActiveConversation: true, clearActiveConversation: true,
); );
} catch (_) { } catch (_) {

View File

@@ -171,8 +171,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
_showConfirmDialog( _showConfirmDialog(
title: 'Clear Chat', title: 'Clear Chat',
message: 'Are you sure you want to clear all messages? This cannot be undone.', message: 'Are you sure you want to clear all messages? This cannot be undone.',
onConfirm: () { onConfirm: () async {
ref.read(messagingProvider.notifier).clearChat(widget.conversationId); await ref.read(messagingProvider.notifier).clearChat(widget.conversationId);
}, },
); );
break; break;
@@ -194,9 +194,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
_showConfirmDialog( _showConfirmDialog(
title: 'Delete Conversation', title: 'Delete Conversation',
message: 'Are you sure you want to delete this conversation? This cannot be undone.', message: 'Are you sure you want to delete this conversation? This cannot be undone.',
onConfirm: () { onConfirm: () async {
ref.read(messagingProvider.notifier).deleteConversation(widget.conversationId); final notifier = ref.read(messagingProvider.notifier);
context.go('/messages'); await notifier.deleteConversation(widget.conversationId);
if (mounted) context.go('/messages');
}, },
isDanger: true, isDanger: true,
); );
@@ -207,7 +208,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
void _showConfirmDialog({ void _showConfirmDialog({
required String title, required String title,
required String message, required String message,
required VoidCallback onConfirm, required Future<void> Function() onConfirm,
bool isDanger = false, bool isDanger = false,
}) { }) {
showDialog( showDialog(

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:real_estate_mobile/core/storage/secure_storage.dart'; import 'package:real_estate_mobile/core/storage/secure_storage.dart';
import 'package:real_estate_mobile/features/messaging/data/socket_service.dart';
import 'package:real_estate_mobile/features/notifications/data/models/notification_model.dart'; import 'package:real_estate_mobile/features/notifications/data/models/notification_model.dart';
import 'package:real_estate_mobile/features/notifications/data/notification_repository.dart'; import 'package:real_estate_mobile/features/notifications/data/notification_repository.dart';
@@ -14,10 +15,16 @@ final unreadCountProvider =
class UnreadCountNotifier extends StateNotifier<int> { class UnreadCountNotifier extends StateNotifier<int> {
final NotificationRepository _repo; final NotificationRepository _repo;
Timer? _timer; Timer? _timer;
final List<StreamSubscription> _subs = [];
UnreadCountNotifier(this._repo) : super(0) { UnreadCountNotifier(this._repo) : super(0) {
refresh(); refresh();
_timer = Timer.periodic(const Duration(seconds: 30), (_) => refresh()); _timer = Timer.periodic(const Duration(seconds: 30), (_) => refresh());
// Refresh count immediately when socket events arrive
final socket = SocketService();
_subs.add(socket.onNewMessage.listen((_) => refresh()));
_subs.add(socket.onConnectionRequest.listen((_) => refresh()));
_subs.add(socket.onConnectionResponse.listen((_) => refresh()));
} }
Future<void> refresh() async { Future<void> refresh() async {
@@ -52,6 +59,9 @@ class UnreadCountNotifier extends StateNotifier<int> {
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
for (final sub in _subs) {
sub.cancel();
}
super.dispose(); super.dispose();
} }
} }