Добавил отправку файлов ! ! !
This commit is contained in:
@@ -12,6 +12,7 @@ import 'package:gwid/models/contact.dart';
|
|||||||
import 'package:gwid/models/message.dart';
|
import 'package:gwid/models/message.dart';
|
||||||
import 'package:gwid/widgets/chat_message_bubble.dart';
|
import 'package:gwid/widgets/chat_message_bubble.dart';
|
||||||
import 'package:gwid/widgets/complaint_dialog.dart';
|
import 'package:gwid/widgets/complaint_dialog.dart';
|
||||||
|
import 'package:gwid/widgets/pinned_message_widget.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:gwid/services/chat_cache_service.dart';
|
import 'package:gwid/services/chat_cache_service.dart';
|
||||||
import 'package:gwid/services/avatar_cache_service.dart';
|
import 'package:gwid/services/avatar_cache_service.dart';
|
||||||
@@ -91,6 +92,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final ValueNotifier<bool> _showScrollToBottomNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _showScrollToBottomNotifier = ValueNotifier(false);
|
||||||
|
|
||||||
late Contact _currentContact;
|
late Contact _currentContact;
|
||||||
|
Message? _pinnedMessage;
|
||||||
|
|
||||||
Message? _replyingToMessage;
|
Message? _replyingToMessage;
|
||||||
|
|
||||||
@@ -175,6 +177,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_currentContact = widget.contact;
|
_currentContact = widget.contact;
|
||||||
|
_pinnedMessage =
|
||||||
|
null; // Будет установлено при получении CONTROL сообщения с event 'pin'
|
||||||
_initializeChat();
|
_initializeChat();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +399,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingHistory = false;
|
_isLoadingHistory = false;
|
||||||
});
|
});
|
||||||
|
_updatePinnedMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -447,6 +452,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_buildChatItems();
|
_buildChatItems();
|
||||||
_isLoadingHistory = false;
|
_isLoadingHistory = false;
|
||||||
});
|
});
|
||||||
|
_updatePinnedMessage();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("❌ Ошибка при загрузке с сервера: $e");
|
print("❌ Ошибка при загрузке с сервера: $e");
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -512,6 +518,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_buildChatItems();
|
_buildChatItems();
|
||||||
_isLoadingMore = false;
|
_isLoadingMore = false;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
_updatePinnedMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isSameDay(DateTime date1, DateTime date2) {
|
bool _isSameDay(DateTime date1, DateTime date2) {
|
||||||
@@ -591,6 +598,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_chatItems = items;
|
_chatItems = items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _updatePinnedMessage() {
|
||||||
|
Message? latestPinned;
|
||||||
|
for (int i = _messages.length - 1; i >= 0; i--) {
|
||||||
|
final message = _messages[i];
|
||||||
|
final controlAttach = message.attaches.firstWhere(
|
||||||
|
(a) => a['_type'] == 'CONTROL',
|
||||||
|
orElse: () => const {},
|
||||||
|
);
|
||||||
|
if (controlAttach.isNotEmpty && controlAttach['event'] == 'pin') {
|
||||||
|
final pinnedMessageData = controlAttach['pinnedMessage'];
|
||||||
|
if (pinnedMessageData != null &&
|
||||||
|
pinnedMessageData is Map<String, dynamic>) {
|
||||||
|
try {
|
||||||
|
latestPinned = Message.fromJson(pinnedMessageData);
|
||||||
|
print('Найдено закрепленное сообщение: ${latestPinned.text}');
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
print('Ошибка парсинга закрепленного сообщения: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_pinnedMessage = latestPinned;
|
||||||
|
if (latestPinned != null) {
|
||||||
|
print('Закрепленное сообщение установлено: ${latestPinned.text}');
|
||||||
|
} else {
|
||||||
|
print('Закрепленное сообщение не найдено');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _addMessage(Message message) {
|
void _addMessage(Message message) {
|
||||||
if (_messages.any((m) => m.id == message.id)) {
|
if (_messages.any((m) => m.id == message.id)) {
|
||||||
print('Сообщение ${message.id} уже существует, пропускаем добавление');
|
print('Сообщение ${message.id} уже существует, пропускаем добавление');
|
||||||
@@ -631,6 +672,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
_chatItems.add(messageItem);
|
_chatItems.add(messageItem);
|
||||||
|
|
||||||
|
// Обновляем закрепленное сообщение
|
||||||
|
_updatePinnedMessage();
|
||||||
|
|
||||||
final theme = context.read<ThemeProvider>();
|
final theme = context.read<ThemeProvider>();
|
||||||
if (theme.messageTransition == TransitionOption.slide) {
|
if (theme.messageTransition == TransitionOption.slide) {
|
||||||
print('Добавлено новое сообщение для анимации Slide+: ${message.id}');
|
print('Добавлено новое сообщение для анимации Slide+: ${message.id}');
|
||||||
@@ -1441,7 +1485,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop(); // Закрываем диалог подтверждения
|
Navigator.of(context).pop();
|
||||||
try {
|
try {
|
||||||
ApiService.instance.leaveGroup(widget.chatId);
|
ApiService.instance.leaveGroup(widget.chatId);
|
||||||
|
|
||||||
@@ -1564,254 +1608,295 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(child: _buildChatWallpaper(theme)),
|
Positioned.fill(child: _buildChatWallpaper(theme)),
|
||||||
if (!_isIdReady || _isLoadingHistory)
|
Column(
|
||||||
const Center(child: CircularProgressIndicator())
|
children: [
|
||||||
else
|
if (_pinnedMessage != null)
|
||||||
ScrollablePositionedList.builder(
|
SafeArea(
|
||||||
itemScrollController: _itemScrollController,
|
child: PinnedMessageWidget(
|
||||||
itemPositionsListener: _itemPositionsListener,
|
pinnedMessage: _pinnedMessage!,
|
||||||
reverse: true,
|
contacts: _contactDetailsCache,
|
||||||
padding: EdgeInsets.fromLTRB(
|
myId: _actualMyId ?? 0,
|
||||||
8.0,
|
onTap: () {
|
||||||
90.0,
|
// TODO: Прокрутить к закрепленному сообщению
|
||||||
8.0,
|
|
||||||
widget.isChannel ? 30.0 : 110.0,
|
|
||||||
),
|
|
||||||
itemCount: _chatItems.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final mappedIndex = _chatItems.length - 1 - index;
|
|
||||||
final item = _chatItems[mappedIndex];
|
|
||||||
final isLastVisual = index == _chatItems.length - 1;
|
|
||||||
|
|
||||||
if (isLastVisual && _hasMore && !_isLoadingMore) {
|
|
||||||
_loadMore();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item is MessageItem) {
|
|
||||||
final message = item.message;
|
|
||||||
final key = _messageKeys.putIfAbsent(
|
|
||||||
message.id,
|
|
||||||
() => GlobalKey(),
|
|
||||||
);
|
|
||||||
final bool isHighlighted =
|
|
||||||
_isSearching &&
|
|
||||||
_searchResults.isNotEmpty &&
|
|
||||||
_currentResultIndex != -1 &&
|
|
||||||
message.id == _searchResults[_currentResultIndex].id;
|
|
||||||
|
|
||||||
final isControlMessage = message.attaches.any(
|
|
||||||
(a) => a['_type'] == 'CONTROL',
|
|
||||||
);
|
|
||||||
if (isControlMessage) {
|
|
||||||
return _ControlMessageChip(
|
|
||||||
message: message,
|
|
||||||
contacts: _contactDetailsCache,
|
|
||||||
myId: _actualMyId ?? widget.myId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final bool isMe = item.message.senderId == _actualMyId;
|
|
||||||
|
|
||||||
MessageReadStatus? readStatus;
|
|
||||||
if (isMe) {
|
|
||||||
final messageId = item.message.id;
|
|
||||||
if (messageId.startsWith('local_')) {
|
|
||||||
readStatus = MessageReadStatus.sending;
|
|
||||||
} else {
|
|
||||||
readStatus = MessageReadStatus.sent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 =
|
|
||||||
_contactDetailsCache[originalSenderId];
|
|
||||||
if (originalSenderContact == null) {
|
|
||||||
_loadContactIfNeeded(originalSenderId);
|
|
||||||
forwardedFrom = 'Участник $originalSenderId';
|
|
||||||
forwardedFromAvatarUrl = null;
|
|
||||||
} else {
|
|
||||||
forwardedFrom = originalSenderContact.name;
|
|
||||||
forwardedFromAvatarUrl =
|
|
||||||
originalSenderContact.photoBaseUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String? senderName;
|
|
||||||
if (widget.isGroupChat && !isMe) {
|
|
||||||
bool shouldShowName = true;
|
|
||||||
if (mappedIndex > 0) {
|
|
||||||
final previousItem = _chatItems[mappedIndex - 1];
|
|
||||||
if (previousItem is MessageItem) {
|
|
||||||
final previousMessage = previousItem.message;
|
|
||||||
if (previousMessage.senderId == message.senderId) {
|
|
||||||
final timeDifferenceInMinutes =
|
|
||||||
(message.time - previousMessage.time) /
|
|
||||||
(1000 * 60);
|
|
||||||
if (timeDifferenceInMinutes < 5) {
|
|
||||||
shouldShowName = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (shouldShowName) {
|
|
||||||
final senderContact =
|
|
||||||
_contactDetailsCache[message.senderId];
|
|
||||||
senderName =
|
|
||||||
senderContact?.name ?? 'Участник ${message.senderId}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final hasPhoto = item.message.attaches.any(
|
|
||||||
(a) => a['_type'] == 'PHOTO',
|
|
||||||
);
|
|
||||||
final isNew = !_animatedMessageIds.contains(item.message.id);
|
|
||||||
final deferImageLoading =
|
|
||||||
hasPhoto &&
|
|
||||||
isNew &&
|
|
||||||
!_anyOptimize &&
|
|
||||||
!context.read<ThemeProvider>().animatePhotoMessages;
|
|
||||||
|
|
||||||
final bubble = ChatMessageBubble(
|
|
||||||
key: key,
|
|
||||||
message: item.message,
|
|
||||||
isMe: isMe,
|
|
||||||
readStatus: readStatus,
|
|
||||||
deferImageLoading: deferImageLoading,
|
|
||||||
myUserId: _actualMyId,
|
|
||||||
chatId: widget.chatId,
|
|
||||||
onReply: widget.isChannel
|
|
||||||
? null
|
|
||||||
: () => _replyToMessage(item.message),
|
|
||||||
onForward: () => _forwardMessage(item.message),
|
|
||||||
onEdit: isMe ? () => _editMessage(item.message) : null,
|
|
||||||
canEditMessage: isMe
|
|
||||||
? item.message.canEdit(_actualMyId!)
|
|
||||||
: null,
|
|
||||||
onDeleteForMe: isMe
|
|
||||||
? () async {
|
|
||||||
await ApiService.instance.deleteMessage(
|
|
||||||
widget.chatId,
|
|
||||||
item.message.id,
|
|
||||||
forMe: true,
|
|
||||||
);
|
|
||||||
widget.onChatUpdated?.call();
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
onDeleteForAll: isMe
|
|
||||||
? () async {
|
|
||||||
await ApiService.instance.deleteMessage(
|
|
||||||
widget.chatId,
|
|
||||||
item.message.id,
|
|
||||||
forMe: false,
|
|
||||||
);
|
|
||||||
widget.onChatUpdated?.call();
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
onReaction: (emoji) {
|
|
||||||
_updateReactionOptimistically(item.message.id, emoji);
|
|
||||||
ApiService.instance.sendReaction(
|
|
||||||
widget.chatId,
|
|
||||||
item.message.id,
|
|
||||||
emoji,
|
|
||||||
);
|
|
||||||
widget.onChatUpdated?.call();
|
|
||||||
},
|
},
|
||||||
onRemoveReaction: () {
|
onClose: () {
|
||||||
_removeReactionOptimistically(item.message.id);
|
setState(() {
|
||||||
ApiService.instance.removeReaction(
|
_pinnedMessage = null;
|
||||||
widget.chatId,
|
});
|
||||||
item.message.id,
|
|
||||||
);
|
|
||||||
widget.onChatUpdated?.call();
|
|
||||||
},
|
},
|
||||||
isGroupChat: widget.isGroupChat,
|
),
|
||||||
isChannel: widget.isChannel,
|
),
|
||||||
senderName: senderName,
|
Expanded(
|
||||||
forwardedFrom: forwardedFrom,
|
child: Stack(
|
||||||
forwardedFromAvatarUrl: forwardedFromAvatarUrl,
|
children: [
|
||||||
contactDetailsCache: _contactDetailsCache,
|
if (!_isIdReady || _isLoadingHistory)
|
||||||
onReplyTap: _scrollToMessage,
|
const Center(child: CircularProgressIndicator())
|
||||||
useAutoReplyColor: context
|
else
|
||||||
.read<ThemeProvider>()
|
ScrollablePositionedList.builder(
|
||||||
.useAutoReplyColor,
|
itemScrollController: _itemScrollController,
|
||||||
customReplyColor: context
|
itemPositionsListener: _itemPositionsListener,
|
||||||
.read<ThemeProvider>()
|
reverse: true,
|
||||||
.customReplyColor,
|
padding: EdgeInsets.fromLTRB(
|
||||||
isFirstInGroup: item.isFirstInGroup,
|
8.0,
|
||||||
isLastInGroup: item.isLastInGroup,
|
8.0, // Убираем дополнительный padding сверху, т.к. теперь pinned message в Column
|
||||||
isGrouped: item.isGrouped,
|
8.0,
|
||||||
avatarVerticalOffset:
|
widget.isChannel ? 30.0 : 110.0,
|
||||||
-8.0, // Смещение аватарки вверх на 8px
|
),
|
||||||
onComplain: () => _showComplaintDialog(item.message.id),
|
itemCount: _chatItems.length,
|
||||||
);
|
itemBuilder: (context, index) {
|
||||||
|
final mappedIndex = _chatItems.length - 1 - index;
|
||||||
|
final item = _chatItems[mappedIndex];
|
||||||
|
final isLastVisual = index == _chatItems.length - 1;
|
||||||
|
|
||||||
Widget finalMessageWidget = bubble as Widget;
|
if (isLastVisual && _hasMore && !_isLoadingMore) {
|
||||||
|
_loadMore();
|
||||||
|
}
|
||||||
|
|
||||||
if (isHighlighted) {
|
if (item is MessageItem) {
|
||||||
return Container(
|
final message = item.message;
|
||||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
final key = _messageKeys.putIfAbsent(
|
||||||
decoration: BoxDecoration(
|
message.id,
|
||||||
color: Theme.of(
|
() => GlobalKey(),
|
||||||
context,
|
);
|
||||||
).colorScheme.primaryContainer.withOpacity(0.5),
|
final bool isHighlighted =
|
||||||
borderRadius: BorderRadius.circular(16),
|
_isSearching &&
|
||||||
border: Border.all(
|
_searchResults.isNotEmpty &&
|
||||||
color: Theme.of(context).colorScheme.primary,
|
_currentResultIndex != -1 &&
|
||||||
width: 1.5,
|
message.id ==
|
||||||
|
_searchResults[_currentResultIndex].id;
|
||||||
|
|
||||||
|
final isControlMessage = message.attaches.any(
|
||||||
|
(a) => a['_type'] == 'CONTROL',
|
||||||
|
);
|
||||||
|
if (isControlMessage) {
|
||||||
|
return _ControlMessageChip(
|
||||||
|
message: message,
|
||||||
|
contacts: _contactDetailsCache,
|
||||||
|
myId: _actualMyId ?? widget.myId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final bool isMe =
|
||||||
|
item.message.senderId == _actualMyId;
|
||||||
|
|
||||||
|
MessageReadStatus? readStatus;
|
||||||
|
if (isMe) {
|
||||||
|
final messageId = item.message.id;
|
||||||
|
if (messageId.startsWith('local_')) {
|
||||||
|
readStatus = MessageReadStatus.sending;
|
||||||
|
} else {
|
||||||
|
readStatus = MessageReadStatus.sent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 =
|
||||||
|
_contactDetailsCache[originalSenderId];
|
||||||
|
if (originalSenderContact == null) {
|
||||||
|
_loadContactIfNeeded(originalSenderId);
|
||||||
|
forwardedFrom =
|
||||||
|
'Участник $originalSenderId';
|
||||||
|
forwardedFromAvatarUrl = null;
|
||||||
|
} else {
|
||||||
|
forwardedFrom =
|
||||||
|
originalSenderContact.name;
|
||||||
|
forwardedFromAvatarUrl =
|
||||||
|
originalSenderContact.photoBaseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String? senderName;
|
||||||
|
if (widget.isGroupChat && !isMe) {
|
||||||
|
bool shouldShowName = true;
|
||||||
|
if (mappedIndex > 0) {
|
||||||
|
final previousItem =
|
||||||
|
_chatItems[mappedIndex - 1];
|
||||||
|
if (previousItem is MessageItem) {
|
||||||
|
final previousMessage = previousItem.message;
|
||||||
|
if (previousMessage.senderId ==
|
||||||
|
message.senderId) {
|
||||||
|
final timeDifferenceInMinutes =
|
||||||
|
(message.time - previousMessage.time) /
|
||||||
|
(1000 * 60);
|
||||||
|
if (timeDifferenceInMinutes < 5) {
|
||||||
|
shouldShowName = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldShowName) {
|
||||||
|
final senderContact =
|
||||||
|
_contactDetailsCache[message.senderId];
|
||||||
|
senderName =
|
||||||
|
senderContact?.name ??
|
||||||
|
'Участник ${message.senderId}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final hasPhoto = item.message.attaches.any(
|
||||||
|
(a) => a['_type'] == 'PHOTO',
|
||||||
|
);
|
||||||
|
final isNew = !_animatedMessageIds.contains(
|
||||||
|
item.message.id,
|
||||||
|
);
|
||||||
|
final deferImageLoading =
|
||||||
|
hasPhoto &&
|
||||||
|
isNew &&
|
||||||
|
!_anyOptimize &&
|
||||||
|
!context
|
||||||
|
.read<ThemeProvider>()
|
||||||
|
.animatePhotoMessages;
|
||||||
|
|
||||||
|
final bubble = ChatMessageBubble(
|
||||||
|
key: key,
|
||||||
|
message: item.message,
|
||||||
|
isMe: isMe,
|
||||||
|
readStatus: readStatus,
|
||||||
|
deferImageLoading: deferImageLoading,
|
||||||
|
myUserId: _actualMyId,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
onReply: widget.isChannel
|
||||||
|
? null
|
||||||
|
: () => _replyToMessage(item.message),
|
||||||
|
onForward: () => _forwardMessage(item.message),
|
||||||
|
onEdit: isMe
|
||||||
|
? () => _editMessage(item.message)
|
||||||
|
: null,
|
||||||
|
canEditMessage: isMe
|
||||||
|
? item.message.canEdit(_actualMyId!)
|
||||||
|
: null,
|
||||||
|
onDeleteForMe: isMe
|
||||||
|
? () async {
|
||||||
|
await ApiService.instance.deleteMessage(
|
||||||
|
widget.chatId,
|
||||||
|
item.message.id,
|
||||||
|
forMe: true,
|
||||||
|
);
|
||||||
|
widget.onChatUpdated?.call();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onDeleteForAll: isMe
|
||||||
|
? () async {
|
||||||
|
await ApiService.instance.deleteMessage(
|
||||||
|
widget.chatId,
|
||||||
|
item.message.id,
|
||||||
|
forMe: false,
|
||||||
|
);
|
||||||
|
widget.onChatUpdated?.call();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onReaction: (emoji) {
|
||||||
|
_updateReactionOptimistically(
|
||||||
|
item.message.id,
|
||||||
|
emoji,
|
||||||
|
);
|
||||||
|
ApiService.instance.sendReaction(
|
||||||
|
widget.chatId,
|
||||||
|
item.message.id,
|
||||||
|
emoji,
|
||||||
|
);
|
||||||
|
widget.onChatUpdated?.call();
|
||||||
|
},
|
||||||
|
onRemoveReaction: () {
|
||||||
|
_removeReactionOptimistically(item.message.id);
|
||||||
|
ApiService.instance.removeReaction(
|
||||||
|
widget.chatId,
|
||||||
|
item.message.id,
|
||||||
|
);
|
||||||
|
widget.onChatUpdated?.call();
|
||||||
|
},
|
||||||
|
isGroupChat: widget.isGroupChat,
|
||||||
|
isChannel: widget.isChannel,
|
||||||
|
senderName: senderName,
|
||||||
|
forwardedFrom: forwardedFrom,
|
||||||
|
forwardedFromAvatarUrl: forwardedFromAvatarUrl,
|
||||||
|
contactDetailsCache: _contactDetailsCache,
|
||||||
|
onReplyTap: _scrollToMessage,
|
||||||
|
useAutoReplyColor: context
|
||||||
|
.read<ThemeProvider>()
|
||||||
|
.useAutoReplyColor,
|
||||||
|
customReplyColor: context
|
||||||
|
.read<ThemeProvider>()
|
||||||
|
.customReplyColor,
|
||||||
|
isFirstInGroup: item.isFirstInGroup,
|
||||||
|
isLastInGroup: item.isLastInGroup,
|
||||||
|
isGrouped: item.isGrouped,
|
||||||
|
avatarVerticalOffset:
|
||||||
|
-8.0, // Смещение аватарки вверх на 8px
|
||||||
|
onComplain: () =>
|
||||||
|
_showComplaintDialog(item.message.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget finalMessageWidget = bubble as Widget;
|
||||||
|
|
||||||
|
if (isHighlighted) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primaryContainer
|
||||||
|
.withOpacity(0.5),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: finalMessageWidget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalMessageWidget;
|
||||||
|
} else if (item is DateSeparatorItem) {
|
||||||
|
return _DateSeparatorChip(date: item.date);
|
||||||
|
}
|
||||||
|
if (isLastVisual && _isLoadingMore) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_showScrollToBottomNotifier.value)
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 120,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.85,
|
||||||
|
child: FloatingActionButton(
|
||||||
|
mini: true,
|
||||||
|
onPressed: _scrollToBottom,
|
||||||
|
child: const Icon(Icons.arrow_downward_rounded),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: finalMessageWidget,
|
],
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
|
],
|
||||||
return finalMessageWidget;
|
|
||||||
} else if (item is DateSeparatorItem) {
|
|
||||||
return _DateSeparatorChip(date: item.date);
|
|
||||||
}
|
|
||||||
if (isLastVisual && _isLoadingMore) {
|
|
||||||
return const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 12),
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
bottom: 120,
|
|
||||||
child: ValueListenableBuilder<bool>(
|
|
||||||
valueListenable: _showScrollToBottomNotifier,
|
|
||||||
builder: (context, showButton, child) {
|
|
||||||
return showButton
|
|
||||||
? Opacity(
|
|
||||||
opacity: 0.85,
|
|
||||||
child: FloatingActionButton(
|
|
||||||
mini: true,
|
|
||||||
onPressed: _scrollToBottom,
|
|
||||||
child: const Icon(Icons.arrow_downward_rounded),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Positioned(left: 0, right: 0, bottom: 0, child: _buildTextInput()),
|
Positioned(left: 0, right: 0, bottom: 0, child: _buildTextInput()),
|
||||||
],
|
],
|
||||||
@@ -4379,6 +4464,16 @@ class _ControlMessageChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return '$senderName присоединился(ась) к группе';
|
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:
|
default:
|
||||||
final eventTypeStr = eventType?.toString() ?? 'неизвестное';
|
final eventTypeStr = eventType?.toString() ?? 'неизвестное';
|
||||||
return 'Событие: $eventTypeStr';
|
return 'Событие: $eventTypeStr';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class Chat {
|
|||||||
final String? baseIconUrl; // URL иконки группы
|
final String? baseIconUrl; // URL иконки группы
|
||||||
final String? description;
|
final String? description;
|
||||||
final int? participantsCount;
|
final int? participantsCount;
|
||||||
|
final Message? pinnedMessage; // Закрепленное сообщение
|
||||||
|
|
||||||
Chat({
|
Chat({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -23,6 +24,7 @@ class Chat {
|
|||||||
this.baseIconUrl,
|
this.baseIconUrl,
|
||||||
this.description,
|
this.description,
|
||||||
this.participantsCount,
|
this.participantsCount,
|
||||||
|
this.pinnedMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Chat.fromJson(Map<String, dynamic> json) {
|
factory Chat.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -31,7 +33,6 @@ class Chat {
|
|||||||
.map((id) => int.parse(id))
|
.map((id) => int.parse(id))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
|
||||||
Message lastMessage;
|
Message lastMessage;
|
||||||
if (json['lastMessage'] != null) {
|
if (json['lastMessage'] != null) {
|
||||||
lastMessage = Message.fromJson(json['lastMessage']);
|
lastMessage = Message.fromJson(json['lastMessage']);
|
||||||
@@ -46,6 +47,11 @@ class Chat {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Message? pinnedMessage;
|
||||||
|
if (json['pinnedMessage'] != null) {
|
||||||
|
pinnedMessage = Message.fromJson(json['pinnedMessage']);
|
||||||
|
}
|
||||||
|
|
||||||
return Chat(
|
return Chat(
|
||||||
id: json['id'] ?? 0,
|
id: json['id'] ?? 0,
|
||||||
ownerId: json['owner'] ?? 0,
|
ownerId: json['owner'] ?? 0,
|
||||||
@@ -57,10 +63,10 @@ class Chat {
|
|||||||
baseIconUrl: json['baseIconUrl'],
|
baseIconUrl: json['baseIconUrl'],
|
||||||
description: json['description'],
|
description: json['description'],
|
||||||
participantsCount: json['participantsCount'],
|
participantsCount: json['participantsCount'],
|
||||||
|
pinnedMessage: pinnedMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
bool get isGroup => type == 'CHAT' || participantIds.length > 2;
|
bool get isGroup => type == 'CHAT' || participantIds.length > 2;
|
||||||
|
|
||||||
List<int> get groupParticipantIds => participantIds;
|
List<int> get groupParticipantIds => participantIds;
|
||||||
@@ -83,6 +89,7 @@ class Chat {
|
|||||||
String? title,
|
String? title,
|
||||||
String? type,
|
String? type,
|
||||||
String? baseIconUrl,
|
String? baseIconUrl,
|
||||||
|
Message? pinnedMessage,
|
||||||
}) {
|
}) {
|
||||||
return Chat(
|
return Chat(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -94,6 +101,8 @@ class Chat {
|
|||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
baseIconUrl: baseIconUrl ?? this.baseIconUrl,
|
baseIconUrl: baseIconUrl ?? this.baseIconUrl,
|
||||||
description: description ?? this.description,
|
description: description ?? this.description,
|
||||||
|
participantsCount: participantsCount,
|
||||||
|
pinnedMessage: pinnedMessage ?? this.pinnedMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,16 @@ class ControlMessageChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return '$senderName присоединился(ась) к группе';
|
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:
|
default:
|
||||||
final eventTypeStr = eventType?.toString() ?? 'неизвестное';
|
final eventTypeStr = eventType?.toString() ?? 'неизвестное';
|
||||||
return 'Событие: $eventTypeStr';
|
return 'Событие: $eventTypeStr';
|
||||||
|
|||||||
98
lib/widgets/pinned_message_widget.dart
Normal file
98
lib/widgets/pinned_message_widget.dart
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gwid/models/message.dart';
|
||||||
|
import 'package:gwid/models/contact.dart';
|
||||||
|
|
||||||
|
class PinnedMessageWidget extends StatelessWidget {
|
||||||
|
final Message pinnedMessage;
|
||||||
|
final Map<int, Contact> contacts;
|
||||||
|
final int myId;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback? onClose;
|
||||||
|
|
||||||
|
const PinnedMessageWidget({
|
||||||
|
super.key,
|
||||||
|
required this.pinnedMessage,
|
||||||
|
required this.contacts,
|
||||||
|
required this.myId,
|
||||||
|
this.onTap,
|
||||||
|
this.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final senderName =
|
||||||
|
contacts[pinnedMessage.senderId]?.name ??
|
||||||
|
(pinnedMessage.senderId == myId ? 'Вы' : 'Неизвестный');
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 0),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surface.withOpacity(0.6),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: colors.outline.withOpacity(0.2), width: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.push_pin,
|
||||||
|
size: 14,
|
||||||
|
color: colors.primary.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '$senderName: ',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: colors.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: pinnedMessage.text.isNotEmpty
|
||||||
|
? pinnedMessage.text
|
||||||
|
: 'Вложение',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.onSurface.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onClose != null)
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onClose,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 16,
|
||||||
|
color: colors.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user