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:
@@ -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?;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
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',
|
svgPath: 'assets/icons/nav_messages_icon.svg',
|
||||||
fallbackIcon: Icons.chat_bubble,
|
fallbackIcon: Icons.chat_bubble,
|
||||||
isSelected: selectedIndex == 2,
|
isSelected: selectedIndex == 2,
|
||||||
|
badgeCount: msgUnread,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final authState = ref.read(authProvider);
|
if (!isAuth) {
|
||||||
if (authState.status != AuthStatus.authenticated) {
|
|
||||||
context.push('/login');
|
context.push('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.go('/messages');
|
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,7 +142,10 @@ 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(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
SvgPicture.asset(
|
||||||
svgPath,
|
svgPath,
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
@@ -144,9 +157,36 @@ class _NavItem extends StatelessWidget {
|
|||||||
placeholderBuilder: (_) => Icon(
|
placeholderBuilder: (_) => Icon(
|
||||||
fallbackIcon,
|
fallbackIcon,
|
||||||
size: 24,
|
size: 24,
|
||||||
color: isSelected ? AppColors.primaryDark : AppColors.accentOrange,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (_) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user