feat: Enable direct chat initiation from agent network, improve GIF rendering in chat, and fix message order and webview authentication.

This commit is contained in:
pradeepkumar
2026-03-15 01:04:36 +05:30
parent 84e9cd9680
commit f659be07ae
5 changed files with 146 additions and 48 deletions

View File

@@ -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/constants/app_colors.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.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/agents/presentation/providers/agent_network_provider.dart';
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
class AgentNetworkScreen extends ConsumerWidget { class AgentNetworkScreen extends ConsumerWidget {
const AgentNetworkScreen({super.key}); const AgentNetworkScreen({super.key});
@@ -406,19 +407,53 @@ class _RequestCard extends ConsumerWidget {
} }
// ── Connection Card (accepted connections in Manage My Network) ── // ── Connection Card (accepted connections in Manage My Network) ──
class _ConnectionCard extends StatelessWidget { class _ConnectionCard extends ConsumerStatefulWidget {
final Map<String, dynamic> data; final Map<String, dynamic> data;
const _ConnectionCard({required this.data}); const _ConnectionCard({required this.data});
@override
ConsumerState<_ConnectionCard> createState() => _ConnectionCardState();
}
class _ConnectionCardState extends ConsumerState<_ConnectionCard> {
bool _isStartingChat = false;
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final user = data['user'] as Map<String, dynamic>? ?? {}; final user = widget.data['user'] as Map<String, dynamic>? ?? {};
final userName = final userName =
user['email'] as String? ?? user['name'] as String? ?? 'User'; user['email'] as String? ?? user['name'] as String? ?? 'User';
final userEmail = user['email'] as String? ?? ''; final userEmail = user['email'] as String? ?? '';
final avatar = user['avatar'] 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); final connectedDate = _formatConnectedDate(respondedAt);
return Padding( return Padding(
@@ -499,10 +534,7 @@ class _ConnectionCard extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
// Message button // Message button
GestureDetector( GestureDetector(
onTap: () { onTap: _isStartingChat ? null : _openChat,
// Navigate to messages — if we have userId, could create/open conversation
context.push('/messages');
},
child: Container( child: Container(
width: 36, width: 36,
height: 36, height: 36,
@@ -510,7 +542,16 @@ class _ConnectionCard extends StatelessWidget {
shape: BoxShape.circle, shape: BoxShape.circle,
color: AppColors.accentOrange.withValues(alpha: 0.1), color: AppColors.accentOrange.withValues(alpha: 0.1),
), ),
child: const Icon(Icons.chat_bubble_outline, 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), size: 18, color: AppColors.accentOrange),
), ),
), ),

View File

@@ -49,6 +49,24 @@ class MessagingRepository {
} }
} }
/// POST /messages/conversations (agent → user)
Future<Conversation> startConversationWithUser(String userId) async {
try {
final response = await _dio.post(
ApiConstants.conversations,
data: {'userId': userId},
);
return Conversation.fromJson(
response.data['data'] as Map<String, dynamic>);
} 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 /// GET /messages/conversations/:id
Future<Conversation> getConversation(String id) async { Future<Conversation> getConversation(String id) async {
try { try {

View File

@@ -221,11 +221,14 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
page: nextPage, 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 paginationInfo = result.pagination;
final existingMessages = final existingMessages =
loadMore ? (state.messages[conversationId] ?? []) : <ChatMessage>[]; loadMore ? (state.messages[conversationId] ?? []) : <ChatMessage>[];
// When loading more (older messages), append to end (older = higher index)
final mergedMessages = final mergedMessages =
loadMore ? [...existingMessages, ...newMessages] : newMessages; loadMore ? [...existingMessages, ...newMessages] : newMessages;
@@ -404,6 +407,36 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
return null; return null;
} }
} }
Future<String?> 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 = final messagingProvider =

View File

@@ -186,6 +186,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
return trimmed.contains('giphy.com/') || trimmed.contains('giphy.gif'); 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 // BUILD
// ============================================================ // ============================================================
@@ -735,6 +740,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
// GIF from Giphy (direct URL) or S3 image // GIF from Giphy (direct URL) or S3 image
final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://'); final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://');
final isGif = _isGifUrl(imageUrl) || message.mimeType == 'image/gif';
return GestureDetector( return GestureDetector(
onTap: () => _showImageFullScreen(context, imageUrl), onTap: () => _showImageFullScreen(context, imageUrl),
@@ -745,7 +751,18 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
maxWidth: 250, maxWidth: 250,
maxHeight: 250, maxHeight: 250,
), ),
child: isDirectUrl child: isGif && isDirectUrl
// Use Image.network for GIFs to preserve animation
? Image.network(
imageUrl,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _buildImagePlaceholder();
},
errorBuilder: (context, error, stack) => _buildImageError(),
)
: isDirectUrl
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -942,8 +959,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Flexible(
child: Text(
senderName, senderName,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontFamily: 'Fractul', fontFamily: 'Fractul',
fontSize: 14, fontSize: 14,
@@ -951,15 +970,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
color: AppColors.primaryDark, color: AppColors.primaryDark,
), ),
), ),
const SizedBox(width: 6),
const Text(
'He/Him',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
@@ -1047,16 +1057,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
), ),
), ),
const SizedBox(width: 6), 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( Text(
timestamp, timestamp,
style: const TextStyle( style: const TextStyle(

View File

@@ -55,6 +55,12 @@ class _StripeCheckoutScreenState extends State<StripeCheckoutScreen> {
} }
return NavigationDecision.navigate; return NavigationDecision.navigate;
}, },
onHttpAuthRequest: (request) {
// Handle auth challenge to prevent WKWebView null assertion crash
request.onProceed(
WebViewCredential(user: '', password: ''),
);
},
), ),
) )
..loadRequest(Uri.parse(widget.url)); ..loadRequest(Uri.parse(widget.url));