From f659be07ae0574c92418cb3080d9951b862c7383 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Sun, 15 Mar 2026 01:04:36 +0530 Subject: [PATCH] feat: Enable direct chat initiation from agent network, improve GIF rendering in chat, and fix message order and webview authentication. --- .../screens/agent_network_screen.dart | 59 +++++++++++--- .../messaging/data/messaging_repository.dart | 18 +++++ .../providers/messaging_provider.dart | 35 ++++++++- .../presentation/screens/chat_screen.dart | 76 +++++++++---------- .../screens/stripe_checkout_screen.dart | 6 ++ 5 files changed, 146 insertions(+), 48 deletions(-) diff --git a/lib/features/agents/presentation/screens/agent_network_screen.dart b/lib/features/agents/presentation/screens/agent_network_screen.dart index 0db4bb8..cf7cd82 100644 --- a/lib/features/agents/presentation/screens/agent_network_screen.dart +++ b/lib/features/agents/presentation/screens/agent_network_screen.dart @@ -5,6 +5,7 @@ import 'package:intl/intl.dart'; import 'package:real_estate_mobile/core/constants/app_colors.dart'; import 'package:real_estate_mobile/core/widgets/s3_image.dart'; import 'package:real_estate_mobile/features/agents/presentation/providers/agent_network_provider.dart'; +import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart'; class AgentNetworkScreen extends ConsumerWidget { const AgentNetworkScreen({super.key}); @@ -406,19 +407,53 @@ class _RequestCard extends ConsumerWidget { } // ── Connection Card (accepted connections in Manage My Network) ── -class _ConnectionCard extends StatelessWidget { +class _ConnectionCard extends ConsumerStatefulWidget { final Map data; const _ConnectionCard({required this.data}); + @override + ConsumerState<_ConnectionCard> createState() => _ConnectionCardState(); +} + +class _ConnectionCardState extends ConsumerState<_ConnectionCard> { + bool _isStartingChat = false; + + Future _openChat() async { + if (_isStartingChat) return; + + final userId = widget.data['userId'] as String?; + if (userId == null) { + context.push('/messages'); + return; + } + + setState(() => _isStartingChat = true); + try { + final conversationId = await ref + .read(messagingProvider.notifier) + .startConversationWithUser(userId); + if (!mounted) return; + if (conversationId != null) { + context.push('/messages/chat/$conversationId'); + } else { + context.push('/messages'); + } + } catch (_) { + if (mounted) context.push('/messages'); + } finally { + if (mounted) setState(() => _isStartingChat = false); + } + } + @override Widget build(BuildContext context) { - final user = data['user'] as Map? ?? {}; + final user = widget.data['user'] as Map? ?? {}; final userName = user['email'] as String? ?? user['name'] as String? ?? 'User'; final userEmail = user['email'] as String? ?? ''; final avatar = user['avatar'] as String?; - final respondedAt = data['respondedAt'] as String?; + final respondedAt = widget.data['respondedAt'] as String?; final connectedDate = _formatConnectedDate(respondedAt); return Padding( @@ -499,10 +534,7 @@ class _ConnectionCard extends StatelessWidget { const SizedBox(width: 8), // Message button GestureDetector( - onTap: () { - // Navigate to messages — if we have userId, could create/open conversation - context.push('/messages'); - }, + onTap: _isStartingChat ? null : _openChat, child: Container( width: 36, height: 36, @@ -510,8 +542,17 @@ class _ConnectionCard extends StatelessWidget { shape: BoxShape.circle, color: AppColors.accentOrange.withValues(alpha: 0.1), ), - child: const Icon(Icons.chat_bubble_outline, - size: 18, color: AppColors.accentOrange), + child: _isStartingChat + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.accentOrange, + ), + ) + : const Icon(Icons.chat_bubble_outline, + size: 18, color: AppColors.accentOrange), ), ), ], diff --git a/lib/features/messaging/data/messaging_repository.dart b/lib/features/messaging/data/messaging_repository.dart index c37ecaf..a7dfd03 100644 --- a/lib/features/messaging/data/messaging_repository.dart +++ b/lib/features/messaging/data/messaging_repository.dart @@ -49,6 +49,24 @@ class MessagingRepository { } } + /// POST /messages/conversations (agent → user) + Future startConversationWithUser(String userId) async { + try { + final response = await _dio.post( + ApiConstants.conversations, + data: {'userId': userId}, + ); + return Conversation.fromJson( + response.data['data'] as Map); + } on DioException catch (e) { + if (e.error is ApiException) throw e.error as ApiException; + throw ApiException( + message: e.message ?? 'Failed to start conversation', + statusCode: e.response?.statusCode, + ); + } + } + /// GET /messages/conversations/:id Future getConversation(String id) async { try { diff --git a/lib/features/messaging/presentation/providers/messaging_provider.dart b/lib/features/messaging/presentation/providers/messaging_provider.dart index 174bb6d..d884e25 100644 --- a/lib/features/messaging/presentation/providers/messaging_provider.dart +++ b/lib/features/messaging/presentation/providers/messaging_provider.dart @@ -221,11 +221,14 @@ class MessagingNotifier extends StateNotifier { page: nextPage, ); - final newMessages = result.messages; + // API returns chronological order (oldest first), but our ListView is + // reverse: true so we need newest-first order (index 0 = newest). + final newMessages = result.messages.reversed.toList(); final paginationInfo = result.pagination; final existingMessages = loadMore ? (state.messages[conversationId] ?? []) : []; + // When loading more (older messages), append to end (older = higher index) final mergedMessages = loadMore ? [...existingMessages, ...newMessages] : newMessages; @@ -404,6 +407,36 @@ class MessagingNotifier extends StateNotifier { return null; } } + + Future startConversationWithUser(String userId) async { + if (!mounted) return null; + state = state.copyWith(isLoadingConversations: true, clearError: true); + try { + final conversation = + await _repository.startConversationWithUser(userId); + + if (!mounted) return conversation.id; + + final exists = state.conversations.any((c) => c.id == conversation.id); + final updatedConversations = exists + ? state.conversations + : [conversation, ...state.conversations]; + + state = state.copyWith( + conversations: updatedConversations, + isLoadingConversations: false, + ); + return conversation.id; + } catch (_) { + if (mounted) { + state = state.copyWith( + isLoadingConversations: false, + error: 'Failed to start conversation', + ); + } + return null; + } + } } final messagingProvider = diff --git a/lib/features/messaging/presentation/screens/chat_screen.dart b/lib/features/messaging/presentation/screens/chat_screen.dart index 46c0f06..4332b5e 100644 --- a/lib/features/messaging/presentation/screens/chat_screen.dart +++ b/lib/features/messaging/presentation/screens/chat_screen.dart @@ -186,6 +186,11 @@ class _ChatScreenState extends ConsumerState { return trimmed.contains('giphy.com/') || trimmed.contains('giphy.gif'); } + bool _isGifUrl(String url) { + final lower = url.trim().toLowerCase(); + return lower.endsWith('.gif') || _isGiphyUrl(url) || lower.contains('tenor.com/'); + } + // ============================================================ // BUILD // ============================================================ @@ -735,6 +740,7 @@ class _ChatScreenState extends ConsumerState { // GIF from Giphy (direct URL) or S3 image final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://'); + final isGif = _isGifUrl(imageUrl) || message.mimeType == 'image/gif'; return GestureDetector( onTap: () => _showImageFullScreen(context, imageUrl), @@ -745,19 +751,30 @@ class _ChatScreenState extends ConsumerState { maxWidth: 250, maxHeight: 250, ), - child: isDirectUrl - ? CachedNetworkImage( - imageUrl: imageUrl, + child: isGif && isDirectUrl + // Use Image.network for GIFs to preserve animation + ? Image.network( + imageUrl, fit: BoxFit.cover, - placeholder: (context, url) => _buildImagePlaceholder(), - errorWidget: (context, url, error) => _buildImageError(), + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _buildImagePlaceholder(); + }, + errorBuilder: (context, error, stack) => _buildImageError(), ) - : S3Image( - imageUrl: imageUrl, - fit: BoxFit.cover, - placeholder: (_) => _buildImagePlaceholder(), - errorWidget: (_) => _buildImageError(), - ), + : isDirectUrl + ? CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => _buildImagePlaceholder(), + errorWidget: (context, url, error) => _buildImageError(), + ) + : S3Image( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (_) => _buildImagePlaceholder(), + errorWidget: (_) => _buildImageError(), + ), ), ), ); @@ -942,23 +959,16 @@ class _ChatScreenState extends ConsumerState { ), ), const SizedBox(width: 6), - Text( - senderName, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(width: 6), - const Text( - 'He/Him', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 10, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, + Flexible( + child: Text( + senderName, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), ), ), const SizedBox(width: 6), @@ -1047,16 +1057,6 @@ class _ChatScreenState extends ConsumerState { ), ), const SizedBox(width: 6), - const Text( - 'He/Him', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 10, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), - const SizedBox(width: 6), Text( timestamp, style: const TextStyle( diff --git a/lib/features/profile/presentation/screens/stripe_checkout_screen.dart b/lib/features/profile/presentation/screens/stripe_checkout_screen.dart index bc12610..169830f 100644 --- a/lib/features/profile/presentation/screens/stripe_checkout_screen.dart +++ b/lib/features/profile/presentation/screens/stripe_checkout_screen.dart @@ -55,6 +55,12 @@ class _StripeCheckoutScreenState extends State { } return NavigationDecision.navigate; }, + onHttpAuthRequest: (request) { + // Handle auth challenge to prevent WKWebView null assertion crash + request.onProceed( + WebViewCredential(user: '', password: ''), + ); + }, ), ) ..loadRequest(Uri.parse(widget.url));