diff --git a/lib/chat_screen.dart b/lib/chat_screen.dart index ac5ee73..d9588ad 100644 --- a/lib/chat_screen.dart +++ b/lib/chat_screen.dart @@ -12,6 +12,7 @@ import 'package:gwid/models/contact.dart'; import 'package:gwid/models/message.dart'; import 'package:gwid/widgets/chat_message_bubble.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:gwid/services/chat_cache_service.dart'; import 'package:gwid/services/avatar_cache_service.dart'; @@ -91,6 +92,7 @@ class _ChatScreenState extends State { final ValueNotifier _showScrollToBottomNotifier = ValueNotifier(false); late Contact _currentContact; + Message? _pinnedMessage; Message? _replyingToMessage; @@ -175,6 +177,8 @@ class _ChatScreenState extends State { void initState() { super.initState(); _currentContact = widget.contact; + _pinnedMessage = + null; // Будет установлено при получении CONTROL сообщения с event 'pin' _initializeChat(); } @@ -395,6 +399,7 @@ class _ChatScreenState extends State { setState(() { _isLoadingHistory = false; }); + _updatePinnedMessage(); } try { @@ -447,6 +452,7 @@ class _ChatScreenState extends State { _buildChatItems(); _isLoadingHistory = false; }); + _updatePinnedMessage(); } catch (e) { print("❌ Ошибка при загрузке с сервера: $e"); if (mounted) { @@ -512,6 +518,7 @@ class _ChatScreenState extends State { _buildChatItems(); _isLoadingMore = false; setState(() {}); + _updatePinnedMessage(); } bool _isSameDay(DateTime date1, DateTime date2) { @@ -591,6 +598,40 @@ class _ChatScreenState extends State { _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) { + 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) { if (_messages.any((m) => m.id == message.id)) { print('Сообщение ${message.id} уже существует, пропускаем добавление'); @@ -631,6 +672,9 @@ class _ChatScreenState extends State { ); _chatItems.add(messageItem); + // Обновляем закрепленное сообщение + _updatePinnedMessage(); + final theme = context.read(); if (theme.messageTransition == TransitionOption.slide) { print('Добавлено новое сообщение для анимации Slide+: ${message.id}'); @@ -1371,7 +1415,7 @@ class _ChatScreenState extends State { builder: (context) => AlertDialog( title: const Text('Удалить чат'), content: Text( - 'Вы уверены, что хотите удалить чат с ${_currentContact.name}? Это действие нельзя отменить.', + 'Вы уверены, что хотите удалить чат с ${_currentContact.name}? Это действие нельзя отменить.', //1231231233 ), actions: [ TextButton( @@ -1441,7 +1485,7 @@ class _ChatScreenState extends State { ), FilledButton( onPressed: () { - Navigator.of(context).pop(); // Закрываем диалог подтверждения + Navigator.of(context).pop(); try { ApiService.instance.leaveGroup(widget.chatId); @@ -1564,254 +1608,295 @@ class _ChatScreenState extends State { body: Stack( children: [ Positioned.fill(child: _buildChatWallpaper(theme)), - if (!_isIdReady || _isLoadingHistory) - const Center(child: CircularProgressIndicator()) - else - ScrollablePositionedList.builder( - itemScrollController: _itemScrollController, - itemPositionsListener: _itemPositionsListener, - reverse: true, - padding: EdgeInsets.fromLTRB( - 8.0, - 90.0, - 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) { - 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?; - 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().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(); + Column( + children: [ + if (_pinnedMessage != null) + SafeArea( + child: PinnedMessageWidget( + pinnedMessage: _pinnedMessage!, + contacts: _contactDetailsCache, + myId: _actualMyId ?? 0, + onTap: () { + // TODO: Прокрутить к закрепленному сообщению }, - onRemoveReaction: () { - _removeReactionOptimistically(item.message.id); - ApiService.instance.removeReaction( - widget.chatId, - item.message.id, - ); - widget.onChatUpdated?.call(); + onClose: () { + setState(() { + _pinnedMessage = null; + }); }, - isGroupChat: widget.isGroupChat, - isChannel: widget.isChannel, - senderName: senderName, - forwardedFrom: forwardedFrom, - forwardedFromAvatarUrl: forwardedFromAvatarUrl, - contactDetailsCache: _contactDetailsCache, - onReplyTap: _scrollToMessage, - useAutoReplyColor: context - .read() - .useAutoReplyColor, - customReplyColor: context - .read() - .customReplyColor, - isFirstInGroup: item.isFirstInGroup, - isLastInGroup: item.isLastInGroup, - isGrouped: item.isGrouped, - avatarVerticalOffset: - -8.0, // Смещение аватарки вверх на 8px - onComplain: () => _showComplaintDialog(item.message.id), - ); + ), + ), + Expanded( + child: Stack( + children: [ + if (!_isIdReady || _isLoadingHistory) + const Center(child: CircularProgressIndicator()) + else + ScrollablePositionedList.builder( + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + reverse: true, + padding: EdgeInsets.fromLTRB( + 8.0, + 8.0, // Убираем дополнительный padding сверху, т.к. теперь pinned message в Column + 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; - Widget finalMessageWidget = bubble as Widget; + if (isLastVisual && _hasMore && !_isLoadingMore) { + _loadMore(); + } - 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, + 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) { + 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?; + 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() + .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() + .useAutoReplyColor, + customReplyColor: context + .read() + .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( - 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()), ], @@ -4379,6 +4464,16 @@ class _ControlMessageChip extends StatelessWidget { } return '$senderName присоединился(ась) к группе'; + case 'pin': + final pinnedMessage = controlAttach['pinnedMessage']; + if (pinnedMessage != null && pinnedMessage is Map) { + final pinnedText = pinnedMessage['text'] as String?; + if (pinnedText != null && pinnedText.isNotEmpty) { + return '$senderDisplayName закрепил(а) сообщение: "$pinnedText"'; + } + } + return '$senderDisplayName закрепил(а) сообщение'; + default: final eventTypeStr = eventType?.toString() ?? 'неизвестное'; return 'Событие: $eventTypeStr'; diff --git a/lib/models/chat.dart b/lib/models/chat.dart index 600129a..8375dc4 100644 --- a/lib/models/chat.dart +++ b/lib/models/chat.dart @@ -11,6 +11,7 @@ class Chat { final String? baseIconUrl; // URL иконки группы final String? description; final int? participantsCount; + final Message? pinnedMessage; // Закрепленное сообщение Chat({ required this.id, @@ -23,6 +24,7 @@ class Chat { this.baseIconUrl, this.description, this.participantsCount, + this.pinnedMessage, }); factory Chat.fromJson(Map json) { @@ -31,7 +33,6 @@ class Chat { .map((id) => int.parse(id)) .toList(); - Message lastMessage; if (json['lastMessage'] != null) { lastMessage = Message.fromJson(json['lastMessage']); @@ -46,6 +47,11 @@ class Chat { ); } + Message? pinnedMessage; + if (json['pinnedMessage'] != null) { + pinnedMessage = Message.fromJson(json['pinnedMessage']); + } + return Chat( id: json['id'] ?? 0, ownerId: json['owner'] ?? 0, @@ -57,10 +63,10 @@ class Chat { baseIconUrl: json['baseIconUrl'], description: json['description'], participantsCount: json['participantsCount'], + pinnedMessage: pinnedMessage, ); } - bool get isGroup => type == 'CHAT' || participantIds.length > 2; List get groupParticipantIds => participantIds; @@ -83,6 +89,7 @@ class Chat { String? title, String? type, String? baseIconUrl, + Message? pinnedMessage, }) { return Chat( id: id, @@ -94,6 +101,8 @@ class Chat { type: type ?? this.type, baseIconUrl: baseIconUrl ?? this.baseIconUrl, description: description ?? this.description, + participantsCount: participantsCount, + pinnedMessage: pinnedMessage ?? this.pinnedMessage, ); } } diff --git a/lib/widgets/message_preview_dialog.dart b/lib/widgets/message_preview_dialog.dart index 60025bd..ef2a7ee 100644 --- a/lib/widgets/message_preview_dialog.dart +++ b/lib/widgets/message_preview_dialog.dart @@ -173,6 +173,16 @@ class ControlMessageChip extends StatelessWidget { } return '$senderName присоединился(ась) к группе'; + case 'pin': + final pinnedMessage = controlAttach['pinnedMessage']; + if (pinnedMessage != null && pinnedMessage is Map) { + final pinnedText = pinnedMessage['text'] as String?; + if (pinnedText != null && pinnedText.isNotEmpty) { + return '$senderDisplayName закрепил(а) сообщение: "$pinnedText"'; + } + } + return '$senderDisplayName закрепил(а) сообщение'; + default: final eventTypeStr = eventType?.toString() ?? 'неизвестное'; return 'Событие: $eventTypeStr'; diff --git a/lib/widgets/pinned_message_widget.dart b/lib/widgets/pinned_message_widget.dart new file mode 100644 index 0000000..297f4d1 --- /dev/null +++ b/lib/widgets/pinned_message_widget.dart @@ -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 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), + ), + ), + ), + ), + ], + ), + ); + } +}