655 lines
24 KiB
Dart
655 lines
24 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'package:gwid/models/chat.dart';
|
||
import 'package:gwid/models/message.dart';
|
||
import 'package:gwid/models/contact.dart';
|
||
import 'package:gwid/models/profile.dart';
|
||
import 'package:gwid/api/api_service.dart';
|
||
import 'package:gwid/widgets/chat_message_bubble.dart';
|
||
import 'package:gwid/widgets/contact_name_widget.dart';
|
||
import 'package:gwid/screens/chat_screen.dart';
|
||
|
||
class ControlMessageChip extends StatelessWidget {
|
||
final Message message;
|
||
final Map<int, Contact> contacts;
|
||
final int myId;
|
||
|
||
const ControlMessageChip({
|
||
super.key,
|
||
required this.message,
|
||
required this.contacts,
|
||
required this.myId,
|
||
});
|
||
|
||
String _formatControlMessage() {
|
||
final controlAttach = message.attaches.firstWhere(
|
||
(a) => a['_type'] == 'CONTROL',
|
||
);
|
||
|
||
final eventType = controlAttach['event'];
|
||
final senderContact = contacts[message.senderId];
|
||
final senderName = senderContact != null
|
||
? getContactDisplayName(
|
||
contactId: senderContact.id,
|
||
originalName: senderContact.name,
|
||
originalFirstName: senderContact.firstName,
|
||
originalLastName: senderContact.lastName,
|
||
)
|
||
: 'ID ${message.senderId}';
|
||
final isMe = message.senderId == myId;
|
||
final senderDisplayName = isMe ? 'Вы' : senderName;
|
||
|
||
String _formatUserList(List<int> userIds) {
|
||
if (userIds.isEmpty) {
|
||
return '';
|
||
}
|
||
final userNames = userIds
|
||
.map((id) {
|
||
if (id == myId) {
|
||
return 'Вы';
|
||
}
|
||
final contact = contacts[id];
|
||
if (contact != null) {
|
||
return getContactDisplayName(
|
||
contactId: contact.id,
|
||
originalName: contact.name,
|
||
originalFirstName: contact.firstName,
|
||
originalLastName: contact.lastName,
|
||
);
|
||
}
|
||
return 'участник с ID $id';
|
||
})
|
||
.where((name) => name.isNotEmpty)
|
||
.join(', ');
|
||
return userNames;
|
||
}
|
||
|
||
switch (eventType) {
|
||
case 'new':
|
||
final title = controlAttach['title'] ?? 'Новая группа';
|
||
return '$senderDisplayName создал(а) группу "$title"';
|
||
|
||
case 'add':
|
||
final userIds = List<int>.from(
|
||
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
|
||
);
|
||
if (userIds.isEmpty) {
|
||
return 'К чату присоединились новые участники';
|
||
}
|
||
final userNames = _formatUserList(userIds);
|
||
if (userNames.isEmpty) {
|
||
return 'К чату присоединились новые участники';
|
||
}
|
||
return '$senderDisplayName добавил(а) в чат: $userNames';
|
||
|
||
case 'remove':
|
||
case 'kick':
|
||
final userIds = List<int>.from(
|
||
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
|
||
);
|
||
if (userIds.isEmpty) {
|
||
return '$senderDisplayName удалил(а) участников из чата';
|
||
}
|
||
final userNames = _formatUserList(userIds);
|
||
if (userNames.isEmpty) {
|
||
return '$senderDisplayName удалил(а) участников из чата';
|
||
}
|
||
|
||
if (userIds.contains(myId)) {
|
||
return 'Вы были удалены из чата';
|
||
}
|
||
return '$senderDisplayName удалил(а) из чата: $userNames';
|
||
|
||
case 'leave':
|
||
if (isMe) {
|
||
return 'Вы покинули группу';
|
||
}
|
||
return '$senderName покинул(а) группу';
|
||
|
||
case 'title':
|
||
final newTitle = controlAttach['title'] ?? '';
|
||
if (newTitle.isEmpty) {
|
||
return '$senderDisplayName изменил(а) название группы';
|
||
}
|
||
return '$senderDisplayName изменил(а) название группы на "$newTitle"';
|
||
|
||
case 'avatar':
|
||
case 'photo':
|
||
return '$senderDisplayName изменил(а) фото группы';
|
||
|
||
case 'description':
|
||
return '$senderDisplayName изменил(а) описание группы';
|
||
|
||
case 'admin':
|
||
case 'promote':
|
||
final userIds = List<int>.from(
|
||
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
|
||
);
|
||
if (userIds.isEmpty) {
|
||
return '$senderDisplayName назначил(а) администраторов';
|
||
}
|
||
final userNames = _formatUserList(userIds);
|
||
if (userNames.isEmpty) {
|
||
return '$senderDisplayName назначил(а) администраторов';
|
||
}
|
||
|
||
if (userIds.contains(myId) && userIds.length == 1) {
|
||
return 'Вас назначили администратором';
|
||
}
|
||
return '$senderDisplayName назначил(а) администраторов: $userNames';
|
||
|
||
case 'demote':
|
||
final userIds = List<int>.from(
|
||
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
|
||
);
|
||
if (userIds.isEmpty) {
|
||
return '$senderDisplayName снял(а) администраторов';
|
||
}
|
||
final userNames = _formatUserList(userIds);
|
||
if (userNames.isEmpty) {
|
||
return '$senderDisplayName снял(а) администраторов';
|
||
}
|
||
|
||
if (userIds.contains(myId) && userIds.length == 1) {
|
||
return 'С вас сняли права администратора';
|
||
}
|
||
return '$senderDisplayName снял(а) права администратора с: $userNames';
|
||
|
||
case 'ban':
|
||
final userIds = List<int>.from(
|
||
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
|
||
);
|
||
if (userIds.isEmpty) {
|
||
return '$senderDisplayName заблокировал(а) участников';
|
||
}
|
||
final userNames = _formatUserList(userIds);
|
||
if (userNames.isEmpty) {
|
||
return '$senderDisplayName заблокировал(а) участников';
|
||
}
|
||
|
||
if (userIds.contains(myId)) {
|
||
return 'Вы были заблокированы в чате';
|
||
}
|
||
return '$senderDisplayName заблокировал(а): $userNames';
|
||
|
||
case 'unban':
|
||
final userIds = List<int>.from(
|
||
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
|
||
);
|
||
if (userIds.isEmpty) {
|
||
return '$senderDisplayName разблокировал(а) участников';
|
||
}
|
||
final userNames = _formatUserList(userIds);
|
||
if (userNames.isEmpty) {
|
||
return '$senderDisplayName разблокировал(а) участников';
|
||
}
|
||
return '$senderDisplayName разблокировал(а): $userNames';
|
||
|
||
case 'join':
|
||
if (isMe) {
|
||
return 'Вы присоединились к группе';
|
||
}
|
||
return '$senderName присоединился(ась) к группе';
|
||
|
||
case 'pin':
|
||
final pinnedMessage = controlAttach['pinnedMessage'];
|
||
if (pinnedMessage != null && pinnedMessage is Map<String, dynamic>) {
|
||
final pinnedText = pinnedMessage['text'] as String?;
|
||
if (pinnedText != null && pinnedText.isNotEmpty) {
|
||
return '$senderDisplayName закрепил(а) сообщение: "$pinnedText"';
|
||
}
|
||
}
|
||
return '$senderDisplayName закрепил(а) сообщение';
|
||
|
||
default:
|
||
final eventTypeStr = eventType?.toString() ?? 'неизвестное';
|
||
return 'Событие: $eventTypeStr';
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
return Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: colors.primaryContainer.withOpacity(0.5),
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Text(
|
||
_formatControlMessage(),
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: colors.onPrimaryContainer,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class MessagePreviewDialog {
|
||
static String _formatTimestamp(int timestamp) {
|
||
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||
final now = DateTime.now();
|
||
if (now.day == dt.day && now.month == dt.month && now.year == dt.year) {
|
||
return DateFormat('HH:mm', 'ru').format(dt);
|
||
} else {
|
||
final yesterday = now.subtract(const Duration(days: 1));
|
||
if (dt.day == yesterday.day &&
|
||
dt.month == yesterday.month &&
|
||
dt.year == yesterday.year) {
|
||
return 'Вчера';
|
||
} else {
|
||
return DateFormat('d MMM', 'ru').format(dt);
|
||
}
|
||
}
|
||
}
|
||
|
||
static bool _isSavedMessages(Chat chat) {
|
||
return chat.id == 0;
|
||
}
|
||
|
||
static bool _isGroupChat(Chat chat) {
|
||
return chat.type == 'CHAT' || chat.participantIds.length > 2;
|
||
}
|
||
|
||
static bool _isSameDay(DateTime date1, DateTime date2) {
|
||
return date1.year == date2.year &&
|
||
date1.month == date2.month &&
|
||
date1.day == date2.day;
|
||
}
|
||
|
||
static bool _isMessageGrouped(
|
||
Message currentMessage,
|
||
Message? previousMessage,
|
||
) {
|
||
if (previousMessage == null) return false;
|
||
|
||
final currentTime = DateTime.fromMillisecondsSinceEpoch(
|
||
currentMessage.time,
|
||
);
|
||
final previousTime = DateTime.fromMillisecondsSinceEpoch(
|
||
previousMessage.time,
|
||
);
|
||
|
||
final timeDifference = currentTime.difference(previousTime).inMinutes;
|
||
|
||
return currentMessage.senderId == previousMessage.senderId &&
|
||
timeDifference <= 5;
|
||
}
|
||
|
||
static String _formatDateSeparator(DateTime date) {
|
||
final now = DateTime.now();
|
||
if (_isSameDay(date, now)) {
|
||
return 'Сегодня';
|
||
} else {
|
||
final yesterday = now.subtract(const Duration(days: 1));
|
||
if (_isSameDay(date, yesterday)) {
|
||
return 'Вчера';
|
||
} else {
|
||
return DateFormat('d MMM yyyy', 'ru').format(date);
|
||
}
|
||
}
|
||
}
|
||
|
||
static String _getChatTitle(Chat chat, Map<int, Contact> contacts) {
|
||
final bool isSavedMessages = _isSavedMessages(chat);
|
||
final bool isGroupChat = _isGroupChat(chat);
|
||
final bool isChannel = chat.type == 'CHANNEL';
|
||
|
||
if (isSavedMessages) {
|
||
return "Избранное";
|
||
} else if (isChannel) {
|
||
return chat.title ?? "Канал";
|
||
} else if (isGroupChat) {
|
||
return chat.title?.isNotEmpty == true ? chat.title! : "Группа";
|
||
} else {
|
||
final myId = chat.ownerId;
|
||
final otherParticipantId = chat.participantIds.firstWhere(
|
||
(id) => id != myId,
|
||
orElse: () => myId,
|
||
);
|
||
final contact = contacts[otherParticipantId];
|
||
|
||
if (contact != null) {
|
||
return contact.name;
|
||
} else if (chat.title?.isNotEmpty == true) {
|
||
return chat.title!;
|
||
} else {
|
||
return "ID $otherParticipantId";
|
||
}
|
||
}
|
||
}
|
||
|
||
static Future<void> show(
|
||
BuildContext context,
|
||
Chat chat,
|
||
Map<int, Contact> contacts,
|
||
Profile? myProfile,
|
||
VoidCallback? onClose,
|
||
Widget Function(BuildContext)? menuBuilder,
|
||
) async {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
List<Message> messages = [];
|
||
bool isLoading = true;
|
||
|
||
try {
|
||
messages = await ApiService.instance.getMessageHistory(
|
||
chat.id,
|
||
force: false,
|
||
);
|
||
if (messages.length > 10) {
|
||
messages = messages.sublist(messages.length - 10);
|
||
}
|
||
} catch (e) {
|
||
print('Ошибка загрузки сообщений для предпросмотра: $e');
|
||
} finally {
|
||
isLoading = false;
|
||
}
|
||
|
||
final Set<int> senderIds = messages.map((m) => m.senderId).toSet();
|
||
senderIds.remove(0);
|
||
|
||
final Set<int> forwardedSenderIds = {};
|
||
for (final message in messages) {
|
||
if (message.isForwarded && message.link != null) {
|
||
final link = message.link;
|
||
if (link is Map<String, dynamic>) {
|
||
final chatName = link['chatName'] as String?;
|
||
if (chatName == null) {
|
||
final forwardedMessage = link['message'] as Map<String, dynamic>?;
|
||
final originalSenderId = forwardedMessage?['sender'] as int?;
|
||
if (originalSenderId != null) {
|
||
forwardedSenderIds.add(originalSenderId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
final allIdsToFetch = {
|
||
...senderIds,
|
||
...forwardedSenderIds,
|
||
}.where((id) => !contacts.containsKey(id)).toList();
|
||
|
||
if (allIdsToFetch.isNotEmpty) {
|
||
try {
|
||
final newContacts = await ApiService.instance.fetchContactsByIds(
|
||
allIdsToFetch,
|
||
);
|
||
for (final contact in newContacts) {
|
||
contacts[contact.id] = contact;
|
||
}
|
||
} catch (e) {
|
||
print('Ошибка загрузки контактов для предпросмотра: $e');
|
||
}
|
||
}
|
||
|
||
final chatTitle = _getChatTitle(chat, contacts);
|
||
final bool isGroupChat = _isGroupChat(chat);
|
||
final bool isChannel = chat.type == 'CHANNEL';
|
||
final myId = myProfile?.id ?? chat.ownerId;
|
||
|
||
if (!context.mounted) return;
|
||
|
||
List<ChatItem> chatItems = [];
|
||
for (int i = 0; i < messages.length; i++) {
|
||
final currentMessage = messages[i];
|
||
final previousMessage = (i > 0) ? messages[i - 1] : null;
|
||
|
||
final currentDate = DateTime.fromMillisecondsSinceEpoch(
|
||
currentMessage.time,
|
||
).toLocal();
|
||
final previousDate = previousMessage != null
|
||
? DateTime.fromMillisecondsSinceEpoch(previousMessage.time).toLocal()
|
||
: null;
|
||
|
||
if (previousMessage == null || !_isSameDay(currentDate, previousDate!)) {
|
||
chatItems.add(DateSeparatorItem(currentDate));
|
||
}
|
||
|
||
final isGrouped = _isMessageGrouped(currentMessage, previousMessage);
|
||
final isFirstInGroup = previousMessage == null || !isGrouped;
|
||
final isLastInGroup =
|
||
i == messages.length - 1 ||
|
||
!_isMessageGrouped(messages[i + 1], currentMessage);
|
||
|
||
chatItems.add(
|
||
MessageItem(
|
||
currentMessage,
|
||
isFirstInGroup: isFirstInGroup,
|
||
isLastInGroup: isLastInGroup,
|
||
isGrouped: isGrouped,
|
||
),
|
||
);
|
||
}
|
||
|
||
await showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (context) {
|
||
return DraggableScrollableSheet(
|
||
initialChildSize: 0.75,
|
||
minChildSize: 0.5,
|
||
maxChildSize: 0.9,
|
||
builder: (context, scrollController) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: colors.surface,
|
||
borderRadius: const BorderRadius.vertical(
|
||
top: Radius.circular(20),
|
||
),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
margin: const EdgeInsets.only(top: 8, bottom: 8),
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: colors.onSurfaceVariant.withOpacity(0.4),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 12,
|
||
),
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: colors.outline.withOpacity(0.2),
|
||
width: 1,
|
||
),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
chatTitle,
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
color: colors.onSurface,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close),
|
||
color: colors.onSurfaceVariant,
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
onClose?.call();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: isLoading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: messages.isEmpty
|
||
? Center(
|
||
child: Text(
|
||
'Нет сообщений',
|
||
style: TextStyle(
|
||
color: colors.onSurfaceVariant,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
)
|
||
: ListView.builder(
|
||
controller: scrollController,
|
||
reverse: true,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8,
|
||
vertical: 8,
|
||
),
|
||
itemCount: chatItems.length,
|
||
itemBuilder: (context, index) {
|
||
final mappedIndex = chatItems.length - 1 - index;
|
||
final item = chatItems[mappedIndex];
|
||
|
||
if (item is DateSeparatorItem) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
vertical: 8,
|
||
horizontal: 16,
|
||
),
|
||
child: Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 4,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: colors.surfaceVariant,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text(
|
||
_formatDateSeparator(item.date),
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: colors.onSurfaceVariant,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
if (item is MessageItem) {
|
||
final message = item.message;
|
||
final isControlMessage = message.attaches.any(
|
||
(a) => a['_type'] == 'CONTROL',
|
||
);
|
||
if (isControlMessage) {
|
||
return ControlMessageChip(
|
||
message: message,
|
||
contacts: contacts,
|
||
myId: myId,
|
||
);
|
||
}
|
||
final isMe = message.senderId == myId;
|
||
final senderContact =
|
||
contacts[message.senderId];
|
||
final senderName = isMe
|
||
? 'Вы'
|
||
: (senderContact?.name ??
|
||
'ID ${message.senderId}');
|
||
|
||
String? forwardedFrom;
|
||
String? forwardedFromAvatarUrl;
|
||
if (message.isForwarded) {
|
||
final link = message.link;
|
||
if (link is Map<String, dynamic>) {
|
||
final chatName =
|
||
link['chatName'] as String?;
|
||
final chatIconUrl =
|
||
link['chatIconUrl'] as String?;
|
||
|
||
if (chatName != null) {
|
||
forwardedFrom = chatName;
|
||
forwardedFromAvatarUrl = chatIconUrl;
|
||
} else {
|
||
final forwardedMessage =
|
||
link['message']
|
||
as Map<String, dynamic>?;
|
||
final originalSenderId =
|
||
forwardedMessage?['sender'] as int?;
|
||
if (originalSenderId != null) {
|
||
final originalSenderContact =
|
||
contacts[originalSenderId];
|
||
forwardedFrom =
|
||
originalSenderContact?.name ??
|
||
'Участник $originalSenderId';
|
||
forwardedFromAvatarUrl =
|
||
originalSenderContact?.photoBaseUrl;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return ChatMessageBubble(
|
||
message: message,
|
||
isMe: isMe,
|
||
readStatus: null,
|
||
deferImageLoading: true,
|
||
myUserId: myId,
|
||
chatId: chat.id,
|
||
onReply: null,
|
||
onEdit: null,
|
||
canEditMessage: null,
|
||
onDeleteForMe: null,
|
||
onDeleteForAll: null,
|
||
onReaction: null,
|
||
onRemoveReaction: null,
|
||
isGroupChat: isGroupChat,
|
||
isChannel: isChannel,
|
||
senderName: senderName,
|
||
forwardedFrom: forwardedFrom,
|
||
forwardedFromAvatarUrl:
|
||
forwardedFromAvatarUrl,
|
||
contactDetailsCache: contacts,
|
||
onReplyTap: null,
|
||
useAutoReplyColor: false,
|
||
customReplyColor: null,
|
||
isFirstInGroup: item.isFirstInGroup,
|
||
isLastInGroup: item.isLastInGroup,
|
||
isGrouped: item.isGrouped,
|
||
avatarVerticalOffset: -8.0,
|
||
);
|
||
}
|
||
|
||
return const SizedBox.shrink();
|
||
},
|
||
),
|
||
),
|
||
if (menuBuilder != null) ...[
|
||
Divider(height: 1, color: colors.outline.withOpacity(0.2)),
|
||
menuBuilder(context),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|