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:dio/dio.dart';
import 'package:real_estate_mobile/config/app_config.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';
/// 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 _codeChallenge;
late final String _stateParam;
@@ -51,21 +57,20 @@ class TwitterAuthService {
Future<Map<String, dynamic>?> exchangeCodeForUser(String code) async {
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(
'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,
},
data: tokenBody,
);
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/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/messaging/presentation/providers/messaging_provider.dart';
/// Determines which tab is active based on the current route.
int _currentTabIndex(BuildContext context) {
@@ -78,19 +79,26 @@ class AppBottomNavBar extends ConsumerWidget {
}
},
),
_NavItem(
svgPath: 'assets/icons/nav_messages_icon.svg',
fallbackIcon: Icons.chat_bubble,
isSelected: selectedIndex == 2,
onTap: () {
final authState = ref.read(authProvider);
if (authState.status != AuthStatus.authenticated) {
context.push('/login');
return;
}
context.go('/messages');
},
),
Builder(builder: (context) {
final authState = ref.watch(authProvider);
final isAuth = authState.status == AuthStatus.authenticated;
final msgUnread = isAuth
? ref.watch(messagingProvider).unreadCount
: 0;
return _NavItem(
svgPath: 'assets/icons/nav_messages_icon.svg',
fallbackIcon: Icons.chat_bubble,
isSelected: selectedIndex == 2,
badgeCount: msgUnread,
onTap: () {
if (!isAuth) {
context.push('/login');
return;
}
context.go('/messages');
},
);
}),
_NavItem(
svgPath: 'assets/icons/nav_profile_icon.svg',
fallbackIcon: Icons.person_outline,
@@ -117,12 +125,14 @@ class _NavItem extends StatelessWidget {
final IconData fallbackIcon;
final bool isSelected;
final VoidCallback onTap;
final int badgeCount;
const _NavItem({
required this.svgPath,
required this.fallbackIcon,
required this.isSelected,
required this.onTap,
this.badgeCount = 0,
});
@override
@@ -132,20 +142,50 @@ class _NavItem extends StatelessWidget {
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SvgPicture.asset(
svgPath,
width: 24,
height: 24,
colorFilter: isSelected
? const ColorFilter.mode(
AppColors.primaryDark, BlendMode.srcIn)
: const ColorFilter.mode(
AppColors.accentOrange, BlendMode.srcIn),
placeholderBuilder: (_) => Icon(
fallbackIcon,
size: 24,
color: isSelected ? AppColors.primaryDark : AppColors.accentOrange,
),
child: Stack(
clipBehavior: Clip.none,
children: [
SvgPicture.asset(
svgPath,
width: 24,
height: 24,
colorFilter: isSelected
? const ColorFilter.mode(
AppColors.primaryDark, BlendMode.srcIn)
: const ColorFilter.mode(
AppColors.accentOrange, BlendMode.srcIn),
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:image_picker/image_picker.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/features/profile/data/profile_repository.dart';
@@ -343,17 +342,31 @@ class _AgentEditProfileScreenState
return Column(
children: [
// Header with circular back button (SVG) matching Figma
// Header with back button
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
GestureDetector(
onTap: () => context.pop(),
child: SvgPicture.asset(
'assets/icons/back_circle_icon.svg',
width: 32,
height: 32,
onTap: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
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),

View File

@@ -66,9 +66,10 @@ class _TwitterLoginScreenState extends State<TwitterLoginScreen> {
final userData = await _authService.exchangeCodeForUser(code);
if (mounted) Navigator.pop(context, userData);
} catch (e) {
debugPrint('Twitter token exchange error: $e');
if (mounted) {
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);
}

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.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/notification_repository.dart';
@@ -14,10 +15,16 @@ final unreadCountProvider =
class UnreadCountNotifier extends StateNotifier<int> {
final NotificationRepository _repo;
Timer? _timer;
final List<StreamSubscription> _subs = [];
UnreadCountNotifier(this._repo) : super(0) {
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 {
@@ -52,6 +59,9 @@ class UnreadCountNotifier extends StateNotifier<int> {
@override
void dispose() {
_timer?.cancel();
for (final sub in _subs) {
sub.cancel();
}
super.dispose();
}
}