diff --git a/lib/core/services/twitter_auth_service.dart b/lib/core/services/twitter_auth_service.dart index 06924a7..19dfe3e 100644 --- a/lib/core/services/twitter_auth_service.dart +++ b/lib/core/services/twitter_auth_service.dart @@ -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?> 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?; diff --git a/lib/core/widgets/app_bottom_nav_bar.dart b/lib/core/widgets/app_bottom_nav_bar.dart index f622355..b627ad5 100644 --- a/lib/core/widgets/app_bottom_nav_bar.dart +++ b/lib/core/widgets/app_bottom_nav_bar.dart @@ -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, + ), + ), + ), + ), + ], ), ), ); diff --git a/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart b/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart index 9479350..026dd44 100644 --- a/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart +++ b/lib/features/agents/presentation/screens/agent_edit_profile_screen.dart @@ -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), diff --git a/lib/features/auth/presentation/screens/twitter_login_screen.dart b/lib/features/auth/presentation/screens/twitter_login_screen.dart index 866499f..70d2981 100644 --- a/lib/features/auth/presentation/screens/twitter_login_screen.dart +++ b/lib/features/auth/presentation/screens/twitter_login_screen.dart @@ -66,9 +66,10 @@ class _TwitterLoginScreenState extends State { 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); } diff --git a/lib/features/messaging/presentation/providers/messaging_provider.dart b/lib/features/messaging/presentation/providers/messaging_provider.dart index c2dfc01..a602240 100644 --- a/lib/features/messaging/presentation/providers/messaging_provider.dart +++ b/lib/features/messaging/presentation/providers/messaging_provider.dart @@ -75,6 +75,7 @@ class MessagingNotifier extends StateNotifier { 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 { return c; }).toList(); + // Recalculate total unread count + final totalUnread = updatedConversations.fold( + 0, (sum, c) => sum + c.unreadCount, + ); + state = state.copyWith( messages: updatedMessages, conversations: updatedConversations, + unreadCount: totalUnread, ); })); @@ -575,9 +582,13 @@ class MessagingNotifier extends StateNotifier { ..remove(conversationId); final updatedConversations = state.conversations.where((c) => c.id != conversationId).toList(); + final totalUnread = updatedConversations.fold( + 0, (sum, c) => sum + c.unreadCount, + ); state = state.copyWith( messages: updatedMessages, conversations: updatedConversations, + unreadCount: totalUnread, clearActiveConversation: true, ); } catch (_) { diff --git a/lib/features/messaging/presentation/screens/chat_screen.dart b/lib/features/messaging/presentation/screens/chat_screen.dart index eae01fc..25283b5 100644 --- a/lib/features/messaging/presentation/screens/chat_screen.dart +++ b/lib/features/messaging/presentation/screens/chat_screen.dart @@ -171,8 +171,8 @@ class _ChatScreenState extends ConsumerState _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 _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 void _showConfirmDialog({ required String title, required String message, - required VoidCallback onConfirm, + required Future Function() onConfirm, bool isDanger = false, }) { showDialog( diff --git a/lib/features/notifications/presentation/providers/notification_provider.dart b/lib/features/notifications/presentation/providers/notification_provider.dart index bb59ce6..2bf00f5 100644 --- a/lib/features/notifications/presentation/providers/notification_provider.dart +++ b/lib/features/notifications/presentation/providers/notification_provider.dart @@ -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 { final NotificationRepository _repo; Timer? _timer; + final List _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 refresh() async { @@ -52,6 +59,9 @@ class UnreadCountNotifier extends StateNotifier { @override void dispose() { _timer?.cancel(); + for (final sub in _subs) { + sub.cancel(); + } super.dispose(); } }