import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:real_estate_mobile/config/app_config.dart'; import 'package:real_estate_mobile/core/constants/app_colors.dart'; import 'package:real_estate_mobile/core/utils/image_url_resolver.dart'; import 'package:real_estate_mobile/core/widgets/s3_image.dart'; import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart'; import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart'; import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart'; import 'package:url_launcher/url_launcher.dart'; class ChatScreen extends ConsumerStatefulWidget { final String conversationId; const ChatScreen({super.key, required this.conversationId}); @override ConsumerState createState() => _ChatScreenState(); } class _ChatScreenState extends ConsumerState { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); final FocusNode _messageFocusNode = FocusNode(); bool _isComposing = false; int _previousMessageCount = 0; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { final notifier = ref.read(messagingProvider.notifier); notifier.setActiveConversation(widget.conversationId); notifier.loadMessages(widget.conversationId); notifier.markAsRead(widget.conversationId); }); _scrollController.addListener(_onScroll); _messageController.addListener(_onTextChanged); } @override void dispose() { ref.read(messagingProvider.notifier).setActiveConversation(null); _messageController.dispose(); _scrollController.dispose(); _messageFocusNode.dispose(); super.dispose(); } void _onScroll() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 50) { ref .read(messagingProvider.notifier) .loadMessages(widget.conversationId, loadMore: true); } } void _onTextChanged() { final composing = _messageController.text.trim().isNotEmpty; if (composing != _isComposing) { setState(() => _isComposing = composing); } } void _scrollToBottom({bool animated = true}) { if (!_scrollController.hasClients) return; if (animated) { _scrollController.animateTo( 0.0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } else { _scrollController.jumpTo(0.0); } } void _sendMessage() { final text = _messageController.text.trim(); if (text.isEmpty) return; ref .read(messagingProvider.notifier) .sendMessage(widget.conversationId, text); _messageController.clear(); setState(() => _isComposing = false); Future.delayed(const Duration(milliseconds: 100), () { _scrollToBottom(); }); } void _handleBack() { ref.read(messagingProvider.notifier).setActiveConversation(null); context.go('/messages'); } // ============================================================ // DATE / TIME FORMATTING // ============================================================ String _formatTime(String isoTimestamp) { try { final dt = DateTime.parse(isoTimestamp).toLocal(); final hour = dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour); final minute = dt.minute.toString().padLeft(2, '0'); final amPm = dt.hour >= 12 ? 'PM' : 'AM'; return '$hour:$minute $amPm'; } catch (_) { return ''; } } String _formatDateSeparator(DateTime date) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final messageDate = DateTime(date.year, date.month, date.day); final diff = today.difference(messageDate).inDays; if (diff == 0) return 'Today'; if (diff == 1) return 'Yesterday'; const months = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; return '${months[date.month - 1]} ${date.day}, ${date.year}'; } bool _shouldShowDateSeparator(List messages, int index) { if (index == messages.length - 1) return true; final current = DateTime.parse(messages[index].createdAt).toLocal(); final older = DateTime.parse(messages[index + 1].createdAt).toLocal(); return DateTime(current.year, current.month, current.day) != DateTime(older.year, older.month, older.day); } String _getInitials(String name) { final parts = name.trim().split(RegExp(r'\s+')); if (parts.isEmpty) return ''; if (parts.length == 1) return parts[0][0].toUpperCase(); return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); } String _resolveAvatarUrl(String? avatarKey) { if (avatarKey == null || avatarKey.isEmpty) return ''; if (avatarKey.startsWith('http://') || avatarKey.startsWith('https://')) { return avatarKey; } final base = AppConfig.storageBaseUrl; if (avatarKey.startsWith('/uploads')) { return '$base$avatarKey'; } return '$base/$avatarKey'; } String _formatFileSize(int? bytes) { if (bytes == null || bytes == 0) return ''; if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } bool _isGiphyUrl(String text) { final trimmed = text.trim(); return trimmed.contains('giphy.com/') || trimmed.contains('giphy.gif'); } // ============================================================ // BUILD // ============================================================ @override Widget build(BuildContext context) { final messagingState = ref.watch(messagingProvider); final currentUserId = ref.read(authProvider).user?.id; final messages = messagingState.messages[widget.conversationId] ?? []; final isTyping = messagingState.typingUsers.isNotEmpty; final conversation = messagingState.conversations .cast() .firstWhere( (c) => c!.id == widget.conversationId, orElse: () => null, ); if (messages.length > _previousMessageCount && _previousMessageCount > 0) { WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(animated: true); }); } _previousMessageCount = messages.length; final otherPartyName = conversation?.otherParty.name ?? 'Chat'; final otherPartyAvatar = conversation?.otherParty.avatar; final isOnline = conversation?.otherParty.isOnline ?? false; final otherPartyHeadline = conversation?.otherParty.headline; return Column( children: [ // Chat header with name + status _buildHeader( name: otherPartyName, isOnline: isOnline, ), Expanded( child: messagingState.isLoadingMessages && messages.isEmpty ? const Center( child: CircularProgressIndicator( color: AppColors.accentOrange, strokeWidth: 2, ), ) : _buildChatBody( messages: messages, currentUserId: currentUserId ?? '', otherPartyName: otherPartyName, otherPartyAvatar: otherPartyAvatar, otherPartyHeadline: otherPartyHeadline, isOnline: isOnline, isTyping: isTyping, isLoadingMore: messagingState.isLoadingMessages && messages.isNotEmpty, ), ), _buildInputArea(), ], ); } // ============================================================ // HEADER // ============================================================ Widget _buildHeader({ required String name, required bool isOnline, }) { return Padding( padding: const EdgeInsets.fromLTRB(12, 10, 12, 6), child: Row( children: [ // Back arrow GestureDetector( onTap: _handleBack, behavior: HitTestBehavior.opaque, child: const Padding( padding: EdgeInsets.only(right: 16), child: Icon( Icons.arrow_back, size: 22, color: AppColors.primaryDark, ), ), ), // Name and active status Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, style: const TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.primaryDark, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Row( children: [ if (isOnline) ...[ Container( width: 8, height: 8, decoration: const BoxDecoration( color: Color(0xFF4CAF50), shape: BoxShape.circle, ), ), const SizedBox(width: 5), ], Text( isOnline ? 'Active Now' : 'Offline', style: const TextStyle( fontFamily: 'Fractul', fontSize: 10, fontWeight: FontWeight.w400, color: AppColors.primaryDark, ), ), ], ), ], ), ), // Three dots (vertical) GestureDetector( onTap: () {}, behavior: HitTestBehavior.opaque, child: const Padding( padding: EdgeInsets.symmetric(horizontal: 10), child: Icon( Icons.more_vert, size: 20, color: AppColors.primaryDark, ), ), ), // Star icon GestureDetector( onTap: () {}, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.only(right: 4), child: Icon( Icons.star_rounded, size: 24, color: AppColors.accentOrange, ), ), ), ], ), ); } // ============================================================ // CHAT BODY // ============================================================ Widget _buildChatBody({ required List messages, required String currentUserId, required String otherPartyName, String? otherPartyAvatar, String? otherPartyHeadline, required bool isOnline, required bool isTyping, required bool isLoadingMore, }) { if (messages.isEmpty) { return SingleChildScrollView( child: Column( children: [ _buildProfileSection( name: otherPartyName, avatar: otherPartyAvatar, headline: otherPartyHeadline, isOnline: isOnline, ), const SizedBox(height: 60), Text( 'No messages yet.\nSay hello!', textAlign: TextAlign.center, style: TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.hintText, ), ), ], ), ); } return _buildMessagesList( messages: messages, currentUserId: currentUserId, currentUserName: 'You', otherPartyName: otherPartyName, otherPartyAvatar: otherPartyAvatar, otherPartyHeadline: otherPartyHeadline, isOnline: isOnline, isTyping: isTyping, isLoadingMore: isLoadingMore, ); } // ============================================================ // PROFILE SECTION // ============================================================ Widget _buildProfileSection({ required String name, String? avatar, String? headline, required bool isOnline, }) { final avatarUrl = _resolveAvatarUrl(avatar); final initials = _getInitials(name); return Padding( padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Large avatar with online dot Stack( children: [ ClipOval( child: avatarUrl.isNotEmpty ? CachedNetworkImage( imageUrl: avatarUrl, width: 80, height: 80, fit: BoxFit.cover, errorWidget: (_, e, s) => _AvatarFallback(letter: initials, size: 80), ) : _AvatarFallback(letter: initials, size: 80), ), if (isOnline) Positioned( right: 2, bottom: 2, child: Container( width: 14, height: 14, decoration: BoxDecoration( color: const Color(0xFF4CAF50), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2.5), ), ), ), ], ), const SizedBox(height: 14), // Name with pronouns and orange dot Row( children: [ Text( name, style: const TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.primaryDark, ), ), const SizedBox(width: 4), const Text( '(He/Him)', style: TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.primaryDark, ), ), const SizedBox(width: 6), Container( width: 10, height: 10, decoration: const BoxDecoration( color: AppColors.accentOrange, shape: BoxShape.circle, ), ), ], ), const SizedBox(height: 12), // Specialties row if (headline != null && headline.isNotEmpty) _buildSpecialtiesRow(headline) else _buildSpecialtiesRow('Sales,Analytics,Inspection,Residential,Commercial'), const SizedBox(height: 12), // Divider Divider( color: AppColors.primaryDark.withValues(alpha: 0.1), thickness: 0.5, ), ], ), ); } Widget _buildSpecialtiesRow(String headline) { final specialties = headline.split(RegExp(r'[,|;]')) .map((s) => s.trim()) .where((s) => s.isNotEmpty) .toList(); if (specialties.isEmpty) return const SizedBox.shrink(); return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ for (int i = 0; i < specialties.length; i++) ...[ Text( specialties[i], style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.primaryDark, ), ), if (i < specialties.length - 1) const SizedBox(width: 16), ], ], ), ); } // ============================================================ // MESSAGES LIST // ============================================================ Widget _buildMessagesList({ required List messages, required String currentUserId, required String currentUserName, required String otherPartyName, String? otherPartyAvatar, String? otherPartyHeadline, required bool isOnline, required bool isTyping, required bool isLoadingMore, }) { final itemCount = messages.length + 1 + (isLoadingMore ? 1 : 0) + (isTyping ? 1 : 0); return ListView.builder( controller: _scrollController, reverse: true, padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), itemCount: itemCount, itemBuilder: (context, index) { if (isTyping && index == 0) { return _buildTypingIndicator(); } final adjustedIndex = isTyping ? index - 1 : index; final profileIndex = messages.length + (isLoadingMore ? 1 : 0); if (adjustedIndex == profileIndex) { return _buildProfileSection( name: otherPartyName, avatar: otherPartyAvatar, headline: otherPartyHeadline, isOnline: isOnline, ); } if (isLoadingMore && adjustedIndex == messages.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator( color: AppColors.accentOrange, strokeWidth: 2, ), ), ), ); } if (adjustedIndex < 0 || adjustedIndex >= messages.length) { return const SizedBox.shrink(); } final message = messages[adjustedIndex]; final isMine = message.isMine(currentUserId); final showDateSeparator = _shouldShowDateSeparator(messages, adjustedIndex); final senderName = isMine ? (message.sender.displayName.isNotEmpty ? message.sender.displayName : currentUserName) : otherPartyName; final senderAvatar = isMine ? null : otherPartyAvatar; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ if (showDateSeparator) _buildDateSeparator( DateTime.parse(message.createdAt).toLocal(), ), Padding( padding: const EdgeInsets.only(bottom: 16), child: isMine ? _buildSentMessage(message, senderName) : _buildReceivedMessage(message, senderName, senderAvatar), ), ], ), ); }, ); } // -- Date Separator -- Widget _buildDateSeparator(DateTime date) { return Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Row( children: [ Expanded( child: Divider( color: AppColors.primaryDark.withValues(alpha: 0.1), thickness: 0.5, ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( _formatDateSeparator(date), style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 12, fontWeight: FontWeight.w400, color: AppColors.subtleText, ), ), ), Expanded( child: Divider( color: AppColors.primaryDark.withValues(alpha: 0.1), thickness: 0.5, ), ), ], ), ); } // ============================================================ // MESSAGE CONTENT (handles text, image, gif, file) // ============================================================ Widget _buildMessageContent(ChatMessage message, {required bool isMine}) { switch (message.messageType) { case MessageType.image: return _buildImageContent(message, isMine: isMine); case MessageType.file: return _buildFileContent(message); case MessageType.system: return _buildSystemContent(message); case MessageType.text: // Detect Giphy URLs sent as text and render as GIF image if (_isGiphyUrl(message.content)) { return _buildImageContent(message, isMine: isMine); } return Text( message.content, style: const TextStyle( fontFamily: 'Fractul', fontSize: 10, fontWeight: FontWeight.w400, color: AppColors.primaryDark, ), textAlign: isMine ? TextAlign.right : TextAlign.left, ); } } // -- Image / GIF content -- Widget _buildImageContent(ChatMessage message, {required bool isMine}) { final imageUrl = message.fileUrl ?? message.content; // GIF from Giphy (direct URL) or S3 image final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://'); return GestureDetector( onTap: () => _showImageFullScreen(context, imageUrl), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: ConstrainedBox( constraints: const BoxConstraints( maxWidth: 250, maxHeight: 250, ), child: 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(), ), ), ), ); } Widget _buildImagePlaceholder() { return Container( width: 200, height: 150, color: const Color(0xFFF0F5FC), child: const Center( child: CircularProgressIndicator( color: AppColors.accentOrange, strokeWidth: 2, ), ), ); } Widget _buildImageError() { return Container( width: 200, height: 150, color: const Color(0xFFF0F5FC), child: const Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.broken_image_outlined, size: 40, color: AppColors.hintText), SizedBox(height: 4), Text( 'Image not available', style: TextStyle( fontFamily: 'SourceSerif4', fontSize: 12, color: AppColors.hintText, ), ), ], ), ); } // -- File content -- Widget _buildFileContent(ChatMessage message) { final fileName = message.fileName ?? 'File'; final fileSize = _formatFileSize(message.fileSize); return GestureDetector( onTap: () => _openOrDownloadFile(message), child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: const Color(0xFFF0F5FC), borderRadius: BorderRadius.circular(12), border: Border.all( color: AppColors.primaryDark.withValues(alpha: 0.1), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: AppColors.accentOrange.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: const Icon( Icons.attach_file, size: 20, color: AppColors.accentOrange, ), ), const SizedBox(width: 10), Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( fileName, style: const TextStyle( fontFamily: 'Fractul', fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.primaryDark, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (fileSize.isNotEmpty) Text( fileSize, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 10, fontWeight: FontWeight.w400, color: AppColors.hintText, ), ), ], ), ), const SizedBox(width: 8), const Icon( Icons.download_rounded, size: 20, color: AppColors.accentOrange, ), ], ), ), ); } // -- System message -- Widget _buildSystemContent(ChatMessage message) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: const Color(0xFFF0F5FC), borderRadius: BorderRadius.circular(8), ), child: Text( message.content, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 12, fontWeight: FontWeight.w400, color: AppColors.hintText, fontStyle: FontStyle.italic, ), textAlign: TextAlign.center, ), ); } // -- Message status indicator -- Widget _buildStatusIcon(MessageStatus status) { switch (status) { case MessageStatus.read: return const Icon(Icons.done_all, size: 14, color: Color(0xFF2196F3)); case MessageStatus.delivered: return Icon(Icons.done_all, size: 14, color: AppColors.hintText); case MessageStatus.sent: return Icon(Icons.done, size: 14, color: AppColors.hintText); } } // ============================================================ // SENT MESSAGE (right-aligned) // ============================================================ Widget _buildSentMessage(ChatMessage message, String senderName) { final timestamp = _formatTime(message.createdAt); final isMediaMessage = message.messageType == MessageType.image || message.messageType == MessageType.file; return Row( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Spacer(), Flexible( flex: 3, child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ // Name row with timestamp and status Row( mainAxisSize: MainAxisSize.min, children: [ _buildStatusIcon(message.status), const SizedBox(width: 4), Text( timestamp, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 10, fontWeight: FontWeight.w200, color: AppColors.primaryDark, ), ), 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, ), ), const SizedBox(width: 6), Container( width: 8, height: 8, decoration: const BoxDecoration( color: AppColors.accentOrange, shape: BoxShape.circle, ), ), ], ), const SizedBox(height: 6), // Message content _buildMessageContent(message, isMine: true), // Show text content below media if present if (isMediaMessage && message.content.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4), child: Text( message.content, style: const TextStyle( fontFamily: 'Fractul', fontSize: 10, fontWeight: FontWeight.w400, color: AppColors.primaryDark, ), textAlign: TextAlign.right, ), ), ], ), ), const SizedBox(width: 10), // Avatar _buildMessageAvatar(null, senderName, 45), ], ); } // ============================================================ // RECEIVED MESSAGE (left-aligned) // ============================================================ Widget _buildReceivedMessage( ChatMessage message, String senderName, String? avatar) { final timestamp = _formatTime(message.createdAt); final isMediaMessage = message.messageType == MessageType.image || message.messageType == MessageType.file; return Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Avatar _buildMessageAvatar(avatar, senderName, 45), const SizedBox(width: 10), Flexible( flex: 3, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Name row with timestamp Row( mainAxisSize: MainAxisSize.min, children: [ Text( senderName, style: const TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.primaryDark, ), ), const SizedBox(width: 6), Container( width: 8, height: 8, decoration: const BoxDecoration( color: AppColors.accentOrange, shape: BoxShape.circle, ), ), 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( fontFamily: 'SourceSerif4', fontSize: 10, fontWeight: FontWeight.w200, color: AppColors.primaryDark, ), ), ], ), const SizedBox(height: 6), // Message content _buildMessageContent(message, isMine: false), // Show text content below media if present if (isMediaMessage && message.content.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4), child: Text( message.content, style: const TextStyle( fontFamily: 'Fractul', fontSize: 10, fontWeight: FontWeight.w400, color: AppColors.primaryDark, ), ), ), ], ), ), const Spacer(), ], ); } // -- Message Avatar -- Widget _buildMessageAvatar(String? avatarKey, String name, double size) { final avatarUrl = _resolveAvatarUrl(avatarKey); final initials = _getInitials(name); return ClipOval( child: avatarUrl.isNotEmpty ? CachedNetworkImage( imageUrl: avatarUrl, width: size, height: size, fit: BoxFit.cover, errorWidget: (_, e, s) => _AvatarFallback(letter: initials, size: size), ) : _AvatarFallback(letter: initials, size: size), ); } // -- Typing Indicator -- Widget _buildTypingIndicator() { return Align( alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ _buildDot(0), const SizedBox(width: 4), _buildDot(1), const SizedBox(width: 4), _buildDot(2), ], ), ), ); } Widget _buildDot(int index) { return TweenAnimationBuilder( tween: Tween(begin: 0.3, end: 1.0), duration: Duration(milliseconds: 600 + (index * 200)), builder: (context, value, child) { return Opacity( opacity: value, child: Container( width: 6, height: 6, decoration: const BoxDecoration( color: AppColors.subtleText, shape: BoxShape.circle, ), ), ); }, ); } // ============================================================ // IMAGE FULL SCREEN VIEWER // ============================================================ void _showImageFullScreen(BuildContext context, String imageUrl) { final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://'); Navigator.of(context).push( MaterialPageRoute( builder: (_) => Scaffold( backgroundColor: Colors.black, appBar: AppBar( backgroundColor: Colors.black, iconTheme: const IconThemeData(color: Colors.white), elevation: 0, ), body: Center( child: InteractiveViewer( minScale: 0.5, maxScale: 4.0, child: isDirectUrl ? CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.contain, placeholder: (context, url) => const Center( child: CircularProgressIndicator(color: Colors.white), ), errorWidget: (context, url, error) => const Icon( Icons.broken_image, size: 80, color: Colors.white54, ), ) : S3Image( imageUrl: imageUrl, fit: BoxFit.contain, placeholder: (_) => const Center( child: CircularProgressIndicator(color: Colors.white), ), errorWidget: (_) => const Icon( Icons.broken_image, size: 80, color: Colors.white54, ), ), ), ), ), ), ); } // ============================================================ // FILE OPEN / DOWNLOAD // ============================================================ Future _openOrDownloadFile(ChatMessage message) async { final fileUrl = message.fileUrl; if (fileUrl == null || fileUrl.isEmpty) return; String? url; if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) { url = fileUrl; } else { // Resolve S3 key to presigned download URL url = await ImageUrlResolver.instance.resolve(fileUrl); } if (url != null && url.isNotEmpty) { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } } } // ============================================================ // INPUT AREA // ============================================================ Widget _buildInputArea() { return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), color: Colors.white, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // Plus icon GestureDetector( onTap: _showAttachmentOptions, behavior: HitTestBehavior.opaque, child: const Padding( padding: EdgeInsets.only(right: 10), child: Icon( Icons.add, size: 24, color: AppColors.primaryDark, ), ), ), // Text input pill with send icon inside Expanded( child: Container( height: 44, margin: const EdgeInsets.only(left: 4), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(22), border: Border.all( color: AppColors.primaryDark, width: 1.5, ), ), clipBehavior: Clip.antiAlias, child: Row( children: [ Expanded( child: TextField( controller: _messageController, focusNode: _messageFocusNode, textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), style: const TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.primaryDark, ), decoration: const InputDecoration( hintText: 'Write a Message', hintStyle: TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.primaryDark, ), contentPadding: EdgeInsets.symmetric( horizontal: 18, vertical: 10, ), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, ), ), ), // Send arrow inside the pill GestureDetector( onTap: _sendMessage, behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.only(right: 14), child: Icon( Icons.send, size: 20, color: AppColors.accentOrange, ), ), ), ], ), ), ), const SizedBox(width: 10), // Mic icon in filled dark circle GestureDetector( onTap: () => _showComingSoon('Speech to text'), child: Container( width: 36, height: 36, decoration: const BoxDecoration( color: AppColors.primaryDark, shape: BoxShape.circle, ), child: const Center( child: Icon( Icons.mic_none, size: 20, color: Colors.white, ), ), ), ), ], ), ); } // ============================================================ // HELPERS // ============================================================ void _showComingSoon(String feature) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$feature coming soon'), duration: const Duration(seconds: 1), backgroundColor: AppColors.primaryDark, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), ); } void _showAttachmentOptions() { showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (ctx) { return Padding( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 40, height: 4, decoration: BoxDecoration( color: AppColors.divider, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 20), _buildAttachmentOption( icon: Icons.image_outlined, label: 'Photo', onTap: () { Navigator.pop(ctx); _showComingSoon('Photo attachment'); }, ), _buildAttachmentOption( icon: Icons.attach_file, label: 'Document', onTap: () { Navigator.pop(ctx); _showComingSoon('Document attachment'); }, ), _buildAttachmentOption( icon: Icons.location_on_outlined, label: 'Location', onTap: () { Navigator.pop(ctx); _showComingSoon('Location sharing'); }, ), const SizedBox(height: 10), ], ), ); }, ); } Widget _buildAttachmentOption({ required IconData icon, required String label, required VoidCallback onTap, }) { return ListTile( leading: Icon(icon, color: AppColors.primaryDark), title: Text( label, style: const TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.primaryDark, ), ), onTap: onTap, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ); } } // -- Avatar Fallback -- class _AvatarFallback extends StatelessWidget { final String letter; final double size; const _AvatarFallback({required this.letter, this.size = 45}); @override Widget build(BuildContext context) { return Container( width: size, height: size, decoration: const BoxDecoration( color: Color(0xFFE8E8E8), shape: BoxShape.circle, ), alignment: Alignment.center, child: Text( letter, style: TextStyle( fontFamily: 'Fractul', fontSize: size * 0.38, fontWeight: FontWeight.w700, color: AppColors.primaryDark, ), ), ); } }