Merge branch 'main' of https://github.com/KometTeam/app
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
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