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: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?;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user