diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 40a0359..28fb06d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -210,6 +210,7 @@ class _ChatScreenState extends State originalText: a.originalText, originalChatId: a.originalChatId, originalAttachments: a.originalAttachments, + originalContact: a.originalContact, ); } return a; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index bbc93f9..74c4ed9 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/backend/modules/contacts.dart'; import 'package:komet/main.dart'; +import 'package:flutter/foundation.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; import '../../models/attachment.dart'; @@ -85,11 +86,17 @@ class MessageBubble extends StatelessWidget { final first = message.attachments!.first; if (first is ForwardedMessageAttachment) { final fwd = first; + final hasContact = fwd.originalContact != null; final hasPhoto = fwd.originalAttachments != null && fwd.originalAttachments!.any((a) => a is PhotoAttachment); - return hasPhoto ? MessageType.attachment : MessageType.text; + final hasOther = + fwd.originalAttachments != null && + fwd.originalAttachments!.isNotEmpty; + if (hasContact || hasPhoto || hasOther) return MessageType.attachment; + return MessageType.text; } + if (first is ContactAttachment) return MessageType.attachment; if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; return MessageType.attachment; @@ -364,8 +371,16 @@ class MessageBubble extends StatelessWidget { ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final attachments = message.attachments; + final isForwardedContact = + attachments != null && + attachments.isNotEmpty && + attachments.first is ForwardedMessageAttachment && + (attachments.first as ForwardedMessageAttachment).originalContact != + null; + final forwarded = _getForwardedAttachment(); - final isForwarded = forwarded != null; + final isForwarded = forwarded != null && !isForwardedContact; // TODO: Нормальное кеширование контактов final ss = messagesModule.searchContactById(message.senderId); @@ -554,15 +569,27 @@ class MessageBubble extends StatelessWidget { final first = attachments.first; if (first is ForwardedMessageAttachment) { final fwd = first; + if (fwd.originalContact != null) { + return _buildForwardedContactContent(context, fwd); + } final photos = fwd.originalAttachments ?.whereType() .toList(); if (photos != null && photos.isNotEmpty) { return _buildForwardedPhotoContent(context, fwd, photos); } + final files = fwd.originalAttachments; + if (files != null && files.isNotEmpty) { + return _buildForwardedGenericContent(context, fwd, files); + } return _buildTextContent(context); } + final contacts = attachments.whereType().toList(); + if (contacts.isNotEmpty) { + return _buildContactAttachment(context, contacts.first); + } + final photos = attachments.whereType().toList(); if (photos.isEmpty) { return _buildGenericAttachment(context, attachments.first); @@ -725,6 +752,136 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildForwardedFileContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List files, + ) { + final cs = Theme.of(context).colorScheme; + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ...files.map((file) => _buildFileAttachment(context, file)), + ], + ); + } + + Widget _buildForwardedGenericContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List attachments, + ) { + debugPrint('DEBUG: _buildForwardedGenericContent called'); + debugPrint('DEBUG: attachments count: ${attachments.length}'); + for (var i = 0; i < attachments.length; i++) { + debugPrint('DEBUG: attachment[$i] type: ${attachments[i].runtimeType}'); + debugPrint('DEBUG: attachment[$i] type field: ${attachments[i].type}'); + } + + final cs = Theme.of(context).colorScheme; + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ...attachments.map((a) { + if (a is FileAttachment) { + return _buildFileAttachment(context, a); + } + return const SizedBox.shrink(); + }), + ], + ); + } + Widget _buildSinglePhoto(BuildContext ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; final width = photo.width?.toDouble() ?? 200; @@ -1134,6 +1291,247 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildContactAttachment(BuildContext ctx, MessageAttachment contact) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final contactData = contact as ContactAttachment; + + final firstName = contactData.firstName ?? ''; + final lastName = contactData.lastName ?? ''; + final hasFirstName = firstName.isNotEmpty; + final hasLastName = lastName.isNotEmpty; + + final name = (hasFirstName || hasLastName) + ? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}' + .trim() + : (contactData.name ?? 'Contact'); + final photoUrl = contactData.photoUrl ?? contactData.baseUrl; + + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.65) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + final bgColor = isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA)); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(24), + ), + child: photoUrl != null && photoUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.network( + photoUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + ) + : Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name.isNotEmpty ? name : 'Contact', + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (contactData.phoneNumber != null) ...[ + const SizedBox(height: 2), + Text( + contactData.phoneNumber!, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildForwardedContactContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + ) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final contact = forwarded.originalContact!; + + final firstName = contact.firstName ?? ''; + final lastName = contact.lastName ?? ''; + final hasFirstName = firstName.isNotEmpty; + final hasLastName = lastName.isNotEmpty; + + final name = (hasFirstName || hasLastName) + ? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}' + .trim() + : (contact.name ?? 'Contact'); + final photoUrl = contact.photoUrl ?? contact.baseUrl; + + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.65) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + final bgColor = isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA)); + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(24), + ), + child: photoUrl != null && photoUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.network( + photoUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + ) + : Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name.isNotEmpty ? name : 'Contact', + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (contact.phoneNumber != null) ...[ + const SizedBox(height: 2), + Text( + contact.phoneNumber!, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ], + ); + } + void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) { // TODO: Open photo viewer } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 2c43222..2d3b6b1 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -325,6 +325,9 @@ class ContactAttachment extends MessageAttachment { final String? firstName; final String? lastName; final String? phoneNumber; + final String? photoUrl; + final int? contactId; + final String? name; const ContactAttachment({ super.previewData, @@ -334,6 +337,9 @@ class ContactAttachment extends MessageAttachment { this.firstName, this.lastName, this.phoneNumber, + this.photoUrl, + this.contactId, + this.name, }) : super(type: AttachmentType.contact); factory ContactAttachment.fromMap(Map map) { @@ -344,6 +350,9 @@ class ContactAttachment extends MessageAttachment { firstName: map['firstName'] as String?, lastName: map['lastName'] as String?, phoneNumber: map['phoneNumber'] as String?, + photoUrl: map['photoUrl'] as String?, + contactId: map['contactId'] as int?, + name: map['name'] as String?, ); } @@ -356,6 +365,9 @@ class ContactAttachment extends MessageAttachment { 'firstName': firstName, 'lastName': lastName, 'phoneNumber': phoneNumber, + 'photoUrl': photoUrl, + 'contactId': contactId, + 'name': name, }; } @@ -442,6 +454,7 @@ class ForwardedMessageAttachment extends MessageAttachment { final String? originalText; final int? originalChatId; final List? originalAttachments; + final ContactAttachment? originalContact; const ForwardedMessageAttachment({ required this.originalSenderId, @@ -452,6 +465,7 @@ class ForwardedMessageAttachment extends MessageAttachment { this.originalText, this.originalChatId, this.originalAttachments, + this.originalContact, }) : super(type: AttachmentType.photo); factory ForwardedMessageAttachment.fromMap(Map map) { @@ -470,11 +484,25 @@ class ForwardedMessageAttachment extends MessageAttachment { } List? originalAttaches; + ContactAttachment? originalContact; if (message != null) { final attaches = message['attaches'] as List?; if (attaches != null) { + final contactAttaches = attaches + .whereType() + .where((a) => (a['_type'] as String?)?.toUpperCase() == 'CONTACT') + .toList(); + if (contactAttaches.isNotEmpty) { + originalContact = ContactAttachment.fromMap( + Map.from(contactAttaches.first), + ); + } originalAttaches = attaches .whereType() + .where((a) { + final type = (a['_type'] as String?)?.toUpperCase(); + return type != 'CONTACT'; + }) .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); } @@ -487,6 +515,7 @@ class ForwardedMessageAttachment extends MessageAttachment { originalText: message?['text'] as String?, originalChatId: link?['chatId'] as int?, originalAttachments: originalAttaches, + originalContact: originalContact, ); }