This commit is contained in:
needle10
2025-11-22 16:01:36 +03:00
4 changed files with 458 additions and 246 deletions

View File

@@ -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}');
@@ -1371,7 +1415,7 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Удалить чат'), title: const Text('Удалить чат'),
content: Text( content: Text(
'Вы уверены, что хотите удалить чат с ${_currentContact.name}? Это действие нельзя отменить.', 'Вы уверены, что хотите удалить чат с ${_currentContact.name}? Это действие нельзя отменить.', //1231231233
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -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,6 +1608,27 @@ class _ChatScreenState extends State<ChatScreen> {
body: Stack( body: Stack(
children: [ children: [
Positioned.fill(child: _buildChatWallpaper(theme)), Positioned.fill(child: _buildChatWallpaper(theme)),
Column(
children: [
if (_pinnedMessage != null)
SafeArea(
child: PinnedMessageWidget(
pinnedMessage: _pinnedMessage!,
contacts: _contactDetailsCache,
myId: _actualMyId ?? 0,
onTap: () {
// TODO: Прокрутить к закрепленному сообщению
},
onClose: () {
setState(() {
_pinnedMessage = null;
});
},
),
),
Expanded(
child: Stack(
children: [
if (!_isIdReady || _isLoadingHistory) if (!_isIdReady || _isLoadingHistory)
const Center(child: CircularProgressIndicator()) const Center(child: CircularProgressIndicator())
else else
@@ -1573,7 +1638,7 @@ class _ChatScreenState extends State<ChatScreen> {
reverse: true, reverse: true,
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
8.0, 8.0,
90.0, 8.0, // Убираем дополнительный padding сверху, т.к. теперь pinned message в Column
8.0, 8.0,
widget.isChannel ? 30.0 : 110.0, widget.isChannel ? 30.0 : 110.0,
), ),
@@ -1597,7 +1662,8 @@ class _ChatScreenState extends State<ChatScreen> {
_isSearching && _isSearching &&
_searchResults.isNotEmpty && _searchResults.isNotEmpty &&
_currentResultIndex != -1 && _currentResultIndex != -1 &&
message.id == _searchResults[_currentResultIndex].id; message.id ==
_searchResults[_currentResultIndex].id;
final isControlMessage = message.attaches.any( final isControlMessage = message.attaches.any(
(a) => a['_type'] == 'CONTROL', (a) => a['_type'] == 'CONTROL',
@@ -1610,7 +1676,8 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
final bool isMe = item.message.senderId == _actualMyId; final bool isMe =
item.message.senderId == _actualMyId;
MessageReadStatus? readStatus; MessageReadStatus? readStatus;
if (isMe) { if (isMe) {
@@ -1628,7 +1695,8 @@ class _ChatScreenState extends State<ChatScreen> {
final link = message.link; final link = message.link;
if (link is Map<String, dynamic>) { if (link is Map<String, dynamic>) {
final chatName = link['chatName'] as String?; final chatName = link['chatName'] as String?;
final chatIconUrl = link['chatIconUrl'] as String?; final chatIconUrl =
link['chatIconUrl'] as String?;
if (chatName != null) { if (chatName != null) {
forwardedFrom = chatName; forwardedFrom = chatName;
@@ -1643,10 +1711,12 @@ class _ChatScreenState extends State<ChatScreen> {
_contactDetailsCache[originalSenderId]; _contactDetailsCache[originalSenderId];
if (originalSenderContact == null) { if (originalSenderContact == null) {
_loadContactIfNeeded(originalSenderId); _loadContactIfNeeded(originalSenderId);
forwardedFrom = 'Участник $originalSenderId'; forwardedFrom =
'Участник $originalSenderId';
forwardedFromAvatarUrl = null; forwardedFromAvatarUrl = null;
} else { } else {
forwardedFrom = originalSenderContact.name; forwardedFrom =
originalSenderContact.name;
forwardedFromAvatarUrl = forwardedFromAvatarUrl =
originalSenderContact.photoBaseUrl; originalSenderContact.photoBaseUrl;
} }
@@ -1658,10 +1728,12 @@ class _ChatScreenState extends State<ChatScreen> {
if (widget.isGroupChat && !isMe) { if (widget.isGroupChat && !isMe) {
bool shouldShowName = true; bool shouldShowName = true;
if (mappedIndex > 0) { if (mappedIndex > 0) {
final previousItem = _chatItems[mappedIndex - 1]; final previousItem =
_chatItems[mappedIndex - 1];
if (previousItem is MessageItem) { if (previousItem is MessageItem) {
final previousMessage = previousItem.message; final previousMessage = previousItem.message;
if (previousMessage.senderId == message.senderId) { if (previousMessage.senderId ==
message.senderId) {
final timeDifferenceInMinutes = final timeDifferenceInMinutes =
(message.time - previousMessage.time) / (message.time - previousMessage.time) /
(1000 * 60); (1000 * 60);
@@ -1675,18 +1747,23 @@ class _ChatScreenState extends State<ChatScreen> {
final senderContact = final senderContact =
_contactDetailsCache[message.senderId]; _contactDetailsCache[message.senderId];
senderName = senderName =
senderContact?.name ?? 'Участник ${message.senderId}'; senderContact?.name ??
'Участник ${message.senderId}';
} }
} }
final hasPhoto = item.message.attaches.any( final hasPhoto = item.message.attaches.any(
(a) => a['_type'] == 'PHOTO', (a) => a['_type'] == 'PHOTO',
); );
final isNew = !_animatedMessageIds.contains(item.message.id); final isNew = !_animatedMessageIds.contains(
item.message.id,
);
final deferImageLoading = final deferImageLoading =
hasPhoto && hasPhoto &&
isNew && isNew &&
!_anyOptimize && !_anyOptimize &&
!context.read<ThemeProvider>().animatePhotoMessages; !context
.read<ThemeProvider>()
.animatePhotoMessages;
final bubble = ChatMessageBubble( final bubble = ChatMessageBubble(
key: key, key: key,
@@ -1700,7 +1777,9 @@ class _ChatScreenState extends State<ChatScreen> {
? null ? null
: () => _replyToMessage(item.message), : () => _replyToMessage(item.message),
onForward: () => _forwardMessage(item.message), onForward: () => _forwardMessage(item.message),
onEdit: isMe ? () => _editMessage(item.message) : null, onEdit: isMe
? () => _editMessage(item.message)
: null,
canEditMessage: isMe canEditMessage: isMe
? item.message.canEdit(_actualMyId!) ? item.message.canEdit(_actualMyId!)
: null, : null,
@@ -1725,7 +1804,10 @@ class _ChatScreenState extends State<ChatScreen> {
} }
: null, : null,
onReaction: (emoji) { onReaction: (emoji) {
_updateReactionOptimistically(item.message.id, emoji); _updateReactionOptimistically(
item.message.id,
emoji,
);
ApiService.instance.sendReaction( ApiService.instance.sendReaction(
widget.chatId, widget.chatId,
item.message.id, item.message.id,
@@ -1759,7 +1841,8 @@ class _ChatScreenState extends State<ChatScreen> {
isGrouped: item.isGrouped, isGrouped: item.isGrouped,
avatarVerticalOffset: avatarVerticalOffset:
-8.0, // Смещение аватарки вверх на 8px -8.0, // Смещение аватарки вверх на 8px
onComplain: () => _showComplaintDialog(item.message.id), onComplain: () =>
_showComplaintDialog(item.message.id),
); );
Widget finalMessageWidget = bubble as Widget; Widget finalMessageWidget = bubble as Widget;
@@ -1768,12 +1851,15 @@ class _ChatScreenState extends State<ChatScreen> {
return Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 2), margin: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: Theme.of(context)
context, .colorScheme
).colorScheme.primaryContainer.withOpacity(0.5), .primaryContainer
.withOpacity(0.5),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.primary, color: Theme.of(
context,
).colorScheme.primary,
width: 1.5, width: 1.5,
), ),
), ),
@@ -1794,25 +1880,24 @@ class _ChatScreenState extends State<ChatScreen> {
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
), ),
if (_showScrollToBottomNotifier.value)
Positioned( Positioned(
right: 16, right: 16,
bottom: 120, bottom: 120,
child: ValueListenableBuilder<bool>( child: Opacity(
valueListenable: _showScrollToBottomNotifier,
builder: (context, showButton, child) {
return showButton
? Opacity(
opacity: 0.85, opacity: 0.85,
child: FloatingActionButton( child: FloatingActionButton(
mini: true, mini: true,
onPressed: _scrollToBottom, onPressed: _scrollToBottom,
child: const Icon(Icons.arrow_downward_rounded), 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';

View File

@@ -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,
); );
} }
} }

View File

@@ -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';

View 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),
),
),
),
),
],
),
);
}
}