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/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<ChatScreen> {
final ValueNotifier<bool> _showScrollToBottomNotifier = ValueNotifier(false);
late Contact _currentContact;
Message? _pinnedMessage;
Message? _replyingToMessage;
@@ -175,6 +177,8 @@ class _ChatScreenState extends State<ChatScreen> {
void initState() {
super.initState();
_currentContact = widget.contact;
_pinnedMessage =
null; // Будет установлено при получении CONTROL сообщения с event 'pin'
_initializeChat();
}
@@ -395,6 +399,7 @@ class _ChatScreenState extends State<ChatScreen> {
setState(() {
_isLoadingHistory = false;
});
_updatePinnedMessage();
}
try {
@@ -447,6 +452,7 @@ class _ChatScreenState extends State<ChatScreen> {
_buildChatItems();
_isLoadingHistory = false;
});
_updatePinnedMessage();
} catch (e) {
print("❌ Ошибка при загрузке с сервера: $e");
if (mounted) {
@@ -512,6 +518,7 @@ class _ChatScreenState extends State<ChatScreen> {
_buildChatItems();
_isLoadingMore = false;
setState(() {});
_updatePinnedMessage();
}
bool _isSameDay(DateTime date1, DateTime date2) {
@@ -591,6 +598,40 @@ class _ChatScreenState extends State<ChatScreen> {
_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) {
if (_messages.any((m) => m.id == message.id)) {
print('Сообщение ${message.id} уже существует, пропускаем добавление');
@@ -631,6 +672,9 @@ class _ChatScreenState extends State<ChatScreen> {
);
_chatItems.add(messageItem);
// Обновляем закрепленное сообщение
_updatePinnedMessage();
final theme = context.read<ThemeProvider>();
if (theme.messageTransition == TransitionOption.slide) {
print('Добавлено новое сообщение для анимации Slide+: ${message.id}');
@@ -1371,7 +1415,7 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => AlertDialog(
title: const Text('Удалить чат'),
content: Text(
'Вы уверены, что хотите удалить чат с ${_currentContact.name}? Это действие нельзя отменить.',
'Вы уверены, что хотите удалить чат с ${_currentContact.name}? Это действие нельзя отменить.', //1231231233
),
actions: [
TextButton(
@@ -1441,7 +1485,7 @@ class _ChatScreenState extends State<ChatScreen> {
),
FilledButton(
onPressed: () {
Navigator.of(context).pop(); // Закрываем диалог подтверждения
Navigator.of(context).pop();
try {
ApiService.instance.leaveGroup(widget.chatId);
@@ -1564,6 +1608,27 @@ class _ChatScreenState extends State<ChatScreen> {
body: Stack(
children: [
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)
const Center(child: CircularProgressIndicator())
else
@@ -1573,7 +1638,7 @@ class _ChatScreenState extends State<ChatScreen> {
reverse: true,
padding: EdgeInsets.fromLTRB(
8.0,
90.0,
8.0, // Убираем дополнительный padding сверху, т.к. теперь pinned message в Column
8.0,
widget.isChannel ? 30.0 : 110.0,
),
@@ -1597,7 +1662,8 @@ class _ChatScreenState extends State<ChatScreen> {
_isSearching &&
_searchResults.isNotEmpty &&
_currentResultIndex != -1 &&
message.id == _searchResults[_currentResultIndex].id;
message.id ==
_searchResults[_currentResultIndex].id;
final isControlMessage = message.attaches.any(
(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;
if (isMe) {
@@ -1628,7 +1695,8 @@ class _ChatScreenState extends State<ChatScreen> {
final link = message.link;
if (link is Map<String, dynamic>) {
final chatName = link['chatName'] as String?;
final chatIconUrl = link['chatIconUrl'] as String?;
final chatIconUrl =
link['chatIconUrl'] as String?;
if (chatName != null) {
forwardedFrom = chatName;
@@ -1643,10 +1711,12 @@ class _ChatScreenState extends State<ChatScreen> {
_contactDetailsCache[originalSenderId];
if (originalSenderContact == null) {
_loadContactIfNeeded(originalSenderId);
forwardedFrom = 'Участник $originalSenderId';
forwardedFrom =
'Участник $originalSenderId';
forwardedFromAvatarUrl = null;
} else {
forwardedFrom = originalSenderContact.name;
forwardedFrom =
originalSenderContact.name;
forwardedFromAvatarUrl =
originalSenderContact.photoBaseUrl;
}
@@ -1658,10 +1728,12 @@ class _ChatScreenState extends State<ChatScreen> {
if (widget.isGroupChat && !isMe) {
bool shouldShowName = true;
if (mappedIndex > 0) {
final previousItem = _chatItems[mappedIndex - 1];
final previousItem =
_chatItems[mappedIndex - 1];
if (previousItem is MessageItem) {
final previousMessage = previousItem.message;
if (previousMessage.senderId == message.senderId) {
if (previousMessage.senderId ==
message.senderId) {
final timeDifferenceInMinutes =
(message.time - previousMessage.time) /
(1000 * 60);
@@ -1675,18 +1747,23 @@ class _ChatScreenState extends State<ChatScreen> {
final senderContact =
_contactDetailsCache[message.senderId];
senderName =
senderContact?.name ?? 'Участник ${message.senderId}';
senderContact?.name ??
'Участник ${message.senderId}';
}
}
final hasPhoto = item.message.attaches.any(
(a) => a['_type'] == 'PHOTO',
);
final isNew = !_animatedMessageIds.contains(item.message.id);
final isNew = !_animatedMessageIds.contains(
item.message.id,
);
final deferImageLoading =
hasPhoto &&
isNew &&
!_anyOptimize &&
!context.read<ThemeProvider>().animatePhotoMessages;
!context
.read<ThemeProvider>()
.animatePhotoMessages;
final bubble = ChatMessageBubble(
key: key,
@@ -1700,7 +1777,9 @@ class _ChatScreenState extends State<ChatScreen> {
? null
: () => _replyToMessage(item.message),
onForward: () => _forwardMessage(item.message),
onEdit: isMe ? () => _editMessage(item.message) : null,
onEdit: isMe
? () => _editMessage(item.message)
: null,
canEditMessage: isMe
? item.message.canEdit(_actualMyId!)
: null,
@@ -1725,7 +1804,10 @@ class _ChatScreenState extends State<ChatScreen> {
}
: null,
onReaction: (emoji) {
_updateReactionOptimistically(item.message.id, emoji);
_updateReactionOptimistically(
item.message.id,
emoji,
);
ApiService.instance.sendReaction(
widget.chatId,
item.message.id,
@@ -1759,7 +1841,8 @@ class _ChatScreenState extends State<ChatScreen> {
isGrouped: item.isGrouped,
avatarVerticalOffset:
-8.0, // Смещение аватарки вверх на 8px
onComplain: () => _showComplaintDialog(item.message.id),
onComplain: () =>
_showComplaintDialog(item.message.id),
);
Widget finalMessageWidget = bubble as Widget;
@@ -1768,12 +1851,15 @@ class _ChatScreenState extends State<ChatScreen> {
return Container(
margin: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.5),
color: Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.primary,
color: Theme.of(
context,
).colorScheme.primary,
width: 1.5,
),
),
@@ -1794,25 +1880,24 @@ class _ChatScreenState extends State<ChatScreen> {
return const SizedBox.shrink();
},
),
if (_showScrollToBottomNotifier.value)
Positioned(
right: 16,
bottom: 120,
child: ValueListenableBuilder<bool>(
valueListenable: _showScrollToBottomNotifier,
builder: (context, showButton, child) {
return showButton
? Opacity(
child: 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<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';

View File

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

View File

@@ -173,6 +173,16 @@ class ControlMessageChip extends StatelessWidget {
}
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';

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