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