Files
fuckKomet/lib/chat_screen.dart

4528 lines
154 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:ui';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:gwid/theme_provider.dart';
import 'package:gwid/api_service.dart';
import 'package:flutter/services.dart';
import 'package:gwid/models/contact.dart';
import 'package:gwid/models/message.dart';
import 'package:gwid/widgets/chat_message_bubble.dart';
import 'package:image_picker/image_picker.dart';
import 'package:gwid/services/chat_cache_service.dart';
import 'package:gwid/services/avatar_cache_service.dart';
import 'package:gwid/services/chat_read_settings_service.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:gwid/screens/group_settings_screen.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:video_player/video_player.dart';
bool _debugShowExactDate = false;
void toggleDebugExactDate() {
_debugShowExactDate = !_debugShowExactDate;
print('Debug режим точной даты: $_debugShowExactDate');
}
abstract class ChatItem {}
class MessageItem extends ChatItem {
final Message message;
final bool isFirstInGroup;
final bool isLastInGroup;
final bool isGrouped;
MessageItem(
this.message, {
this.isFirstInGroup = false,
this.isLastInGroup = false,
this.isGrouped = false,
});
}
class DateSeparatorItem extends ChatItem {
final DateTime date;
DateSeparatorItem(this.date);
}
class ChatScreen extends StatefulWidget {
final int chatId;
final Contact contact;
final int myId;
final VoidCallback? onChatUpdated;
final bool isGroupChat;
final bool isChannel;
final int? participantCount;
final bool isDesktopMode;
const ChatScreen({
super.key,
required this.chatId,
required this.contact,
required this.myId,
this.onChatUpdated,
this.isGroupChat = false,
this.isChannel = false,
this.participantCount,
this.isDesktopMode = false,
});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final List<Message> _messages = [];
List<ChatItem> _chatItems = [];
final Set<String> _animatedMessageIds = {};
bool _isLoadingHistory = true;
final TextEditingController _textController = TextEditingController();
final FocusNode _textFocusNode = FocusNode();
StreamSubscription? _apiSubscription;
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
ItemPositionsListener.create();
final ValueNotifier<bool> _showScrollToBottomNotifier = ValueNotifier(false);
late Contact _currentContact;
Message? _replyingToMessage;
final Map<int, Contact> _contactDetailsCache = {};
final Set<int> _loadingContactIds = {};
final Map<String, String> _lastReadMessageIdByParticipant = {};
int? _actualMyId;
bool _isIdReady = false;
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
List<Message> _searchResults = [];
int _currentResultIndex = -1;
final Map<String, GlobalKey> _messageKeys = {};
void _checkContactCache() {
if (widget.chatId == 0) {
return;
}
final cachedContact = ApiService.instance.getCachedContact(
widget.contact.id,
);
if (cachedContact != null) {
_currentContact = cachedContact;
if (mounted) {
setState(() {});
}
}
}
void _scrollToBottom() {
_itemScrollController.scrollTo(
index: 0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
void _loadContactDetails() {
final chatData = ApiService.instance.lastChatsPayload;
if (chatData != null && chatData['contacts'] != null) {
final contactsJson = chatData['contacts'] as List<dynamic>;
for (var contactJson in contactsJson) {
final contact = Contact.fromJson(contactJson);
_contactDetailsCache[contact.id] = contact;
}
print(
'Кэш контактов для экрана чата заполнен: ${_contactDetailsCache.length} контактов.',
);
}
}
Future<void> _loadContactIfNeeded(int contactId) async {
if (_contactDetailsCache.containsKey(contactId) ||
_loadingContactIds.contains(contactId)) {
return;
}
_loadingContactIds.add(contactId);
try {
final contacts = await ApiService.instance.fetchContactsByIds([
contactId,
]);
if (contacts.isNotEmpty && mounted) {
final contact = contacts.first;
_contactDetailsCache[contact.id] = contact;
setState(() {});
}
} catch (e) {
print('Ошибка загрузки контакта $contactId: $e');
} finally {
_loadingContactIds.remove(contactId);
}
}
@override
void initState() {
super.initState();
_currentContact = widget.contact;
_initializeChat();
}
Future<void> _initializeChat() async {
await _loadCachedContacts();
final profileData = ApiService.instance.lastChatsPayload?['profile'];
final contactProfile = profileData?['contact'] as Map<String, dynamic>?;
if (contactProfile != null &&
contactProfile['id'] != null &&
contactProfile['id'] != 0) {
_actualMyId = contactProfile['id'];
print('✅ ID пользователя успешно получен из ApiService: $_actualMyId');
} else {
_actualMyId = widget.myId;
print('ПРЕДУПРЕЖДЕНИЕ: Используется ID из виджета: $_actualMyId');
}
if (mounted) {
setState(() {
_isIdReady = true;
});
}
_loadContactDetails();
_checkContactCache();
if (!ApiService.instance.isContactCacheValid()) {
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
ApiService.instance.getBlockedContacts();
}
});
}
ApiService.instance.contactUpdates.listen((contact) {
if (widget.chatId == 0) {
return;
}
if (contact.id == _currentContact.id && mounted) {
ApiService.instance.updateCachedContact(contact);
setState(() {
_currentContact = contact;
});
}
});
_itemPositionsListener.itemPositions.addListener(() {
final positions = _itemPositionsListener.itemPositions.value;
if (positions.isNotEmpty) {
_showScrollToBottomNotifier.value = positions.first.index > 0;
}
});
_searchController.addListener(() {
if (_searchController.text.isEmpty && _searchResults.isNotEmpty) {
setState(() {
_searchResults.clear();
_currentResultIndex = -1;
});
} else if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
});
_loadHistoryAndListen();
}
@override
void didUpdateWidget(ChatScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.contact.id != widget.contact.id) {
_currentContact = widget.contact;
_checkContactCache();
if (!ApiService.instance.isContactCacheValid()) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
ApiService.instance.getBlockedContacts();
}
});
}
}
}
void _loadHistoryAndListen() {
_paginateInitialLoad();
_apiSubscription = ApiService.instance.messages.listen((message) {
if (!mounted) return;
final opcode = message['opcode'];
final cmd = message['cmd'];
final payload = message['payload'];
if (payload == null) return;
final dynamic incomingChatId = payload['chatId'];
final int? chatIdNormalized = incomingChatId is int
? incomingChatId
: int.tryParse(incomingChatId?.toString() ?? '');
if (opcode == 64 && cmd == 1) {
if (chatIdNormalized == widget.chatId) {
final newMessage = Message.fromJson(payload['message']);
print(
'Получено подтверждение (Opcode 64) для cid: ${newMessage.cid}. Обновляем сообщение.',
);
_updateMessage(
newMessage,
); // Обновляем временное сообщение на настоящее
}
} else if (opcode == 128) {
if (chatIdNormalized == widget.chatId) {
final newMessage = Message.fromJson(payload['message']);
final hasSameId = _messages.any((m) => m.id == newMessage.id);
final hasSameCid =
newMessage.cid != null &&
_messages.any((m) => m.cid != null && m.cid == newMessage.cid);
if (hasSameId || hasSameCid) {
_updateMessage(newMessage);
} else {
_addMessage(newMessage);
}
}
} else if (opcode == 129) {
if (chatIdNormalized == widget.chatId) {
print('Пользователь печатает в чате $chatIdNormalized');
}
} else if (opcode == 132) {
if (chatIdNormalized == widget.chatId) {
print('Обновлен статус присутствия для чата $chatIdNormalized');
final dynamic contactIdAny =
payload['contactId'] ?? payload['userId'];
if (contactIdAny != null) {
final int? cid = contactIdAny is int
? contactIdAny
: int.tryParse(contactIdAny.toString());
if (cid != null) {
final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final isOnline = payload['online'] == true;
final userPresence = {
'seen': currentTime,
'on': isOnline ? 'ON' : 'OFF',
};
ApiService.instance.updatePresenceData({
cid.toString(): userPresence,
});
print(
'Обновлен presence для пользователя $cid: online=$isOnline, seen=$currentTime',
);
if (mounted) {
setState(() {});
}
}
}
}
} else if (opcode == 67) {
if (chatIdNormalized == widget.chatId) {
final editedMessage = Message.fromJson(payload['message']);
_updateMessage(editedMessage);
}
} else if (opcode == 66) {
if (chatIdNormalized == widget.chatId) {
final deletedMessageIds = List<String>.from(
payload['messageIds'] ?? [],
);
_removeMessages(deletedMessageIds);
}
} else if (opcode == 178) {
if (chatIdNormalized == widget.chatId) {
final messageId = payload['messageId'] as String?;
final reactionInfo = payload['reactionInfo'] as Map<String, dynamic>?;
if (messageId != null && reactionInfo != null) {
_updateMessageReaction(messageId, reactionInfo);
}
}
} else if (opcode == 179) {
if (chatIdNormalized == widget.chatId) {
final messageId = payload['messageId'] as String?;
final reactionInfo = payload['reactionInfo'] as Map<String, dynamic>?;
if (messageId != null) {
_updateMessageReaction(messageId, reactionInfo ?? {});
}
}
}
});
}
static const int _pageSize = 50;
bool _isLoadingMore = false;
bool _hasMore = true;
int? _oldestLoadedTime;
bool get _optimize => context.read<ThemeProvider>().optimizeChats;
bool get _ultraOptimize => context.read<ThemeProvider>().ultraOptimizeChats;
bool get _anyOptimize => _optimize || _ultraOptimize;
int get _optPage => _ultraOptimize ? 10 : (_optimize ? 50 : _pageSize);
Future<void> _paginateInitialLoad() async {
setState(() => _isLoadingHistory = true);
final chatCacheService = ChatCacheService();
List<Message>? cachedMessages = await chatCacheService
.getCachedChatMessages(widget.chatId);
bool hasCache = cachedMessages != null && cachedMessages.isNotEmpty;
if (hasCache) {
print("✅ Показываем ${cachedMessages.length} сообщений из кэша...");
if (!mounted) return;
_messages.clear();
_messages.addAll(cachedMessages);
_buildChatItems();
setState(() {
_isLoadingHistory = false;
});
}
try {
print("📡 Запрашиваем актуальные сообщения с сервера...");
final allMessages = await ApiService.instance.getMessageHistory(
widget.chatId,
force: true,
);
if (!mounted) return;
print("✅ Получено ${allMessages.length} сообщений с сервера.");
final Set<int> senderIds = {};
for (final message in allMessages) {
senderIds.add(message.senderId);
if (message.isReply && message.link?['message']?['sender'] != null) {
final replySenderId = message.link!['message']!['sender'];
if (replySenderId is int) {
senderIds.add(replySenderId);
}
}
}
senderIds.remove(0); // Удаляем системный ID, если он есть
final idsToFetch = senderIds
.where((id) => !_contactDetailsCache.containsKey(id))
.toList();
if (idsToFetch.isNotEmpty) {
final newContacts = await ApiService.instance.fetchContactsByIds(
idsToFetch,
);
for (final contact in newContacts) {
_contactDetailsCache[contact.id] = contact;
}
}
await chatCacheService.cacheChatMessages(widget.chatId, allMessages);
final page = _anyOptimize ? _optPage : _pageSize;
final slice = allMessages.length > page
? allMessages.sublist(allMessages.length - page)
: allMessages;
setState(() {
_messages.clear();
_messages.addAll(slice);
_oldestLoadedTime = _messages.isNotEmpty ? _messages.first.time : null;
_hasMore = allMessages.length > _messages.length;
_buildChatItems();
_isLoadingHistory = false;
});
} catch (e) {
print("❌ Ошибка при загрузке с сервера: $e");
if (mounted) {
setState(() {
_isLoadingHistory = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось обновить историю чата')),
);
}
}
final readSettings = await ChatReadSettingsService.instance.getSettings(
widget.chatId,
);
final theme = context.read<ThemeProvider>();
final shouldReadOnEnter = readSettings != null
? (!readSettings.disabled && readSettings.readOnEnter)
: theme.debugReadOnEnter;
if (shouldReadOnEnter &&
_messages.isNotEmpty &&
widget.onChatUpdated != null) {
final lastMessageId = _messages.last.id;
ApiService.instance.markMessageAsRead(widget.chatId, lastMessageId);
}
}
Future<void> _loadMore() async {
if (_isLoadingMore || !_hasMore) return;
_isLoadingMore = true;
setState(() {});
final all = await ApiService.instance.getMessageHistory(
widget.chatId,
force: false,
);
if (!mounted) return;
final page = _anyOptimize ? _optPage : _pageSize;
final older = all
.where((m) => m.time < (_oldestLoadedTime ?? 1 << 62))
.toList();
if (older.isEmpty) {
_hasMore = false;
_isLoadingMore = false;
setState(() {});
return;
}
older.sort((a, b) => a.time.compareTo(b.time));
final take = older.length > page
? older.sublist(older.length - page)
: older;
_messages.insertAll(0, take);
_oldestLoadedTime = _messages.first.time;
_hasMore = all.length > _messages.length;
_buildChatItems();
_isLoadingMore = false;
setState(() {});
}
bool _isSameDay(DateTime date1, DateTime date2) {
return date1.year == date2.year &&
date1.month == date2.month &&
date1.day == date2.day;
}
bool _isMessageGrouped(Message currentMessage, Message? previousMessage) {
if (previousMessage == null) return false;
final currentTime = DateTime.fromMillisecondsSinceEpoch(
currentMessage.time,
);
final previousTime = DateTime.fromMillisecondsSinceEpoch(
previousMessage.time,
);
final timeDifference = currentTime.difference(previousTime).inMinutes;
return currentMessage.senderId == previousMessage.senderId &&
timeDifference <= 5;
}
void _buildChatItems() {
final List<ChatItem> items = [];
final source = _messages;
for (int i = 0; i < source.length; i++) {
final currentMessage = source[i];
final previousMessage = (i > 0) ? source[i - 1] : null;
final currentDate = DateTime.fromMillisecondsSinceEpoch(
currentMessage.time,
).toLocal();
final previousDate = previousMessage != null
? DateTime.fromMillisecondsSinceEpoch(previousMessage.time).toLocal()
: null;
if (previousMessage == null || !_isSameDay(currentDate, previousDate!)) {
items.add(DateSeparatorItem(currentDate));
}
final isGrouped = _isMessageGrouped(currentMessage, previousMessage);
print(
'DEBUG GROUPING: Message ${i}: sender=${currentMessage.senderId}, time=${currentMessage.time}',
);
if (previousMessage != null) {
print(
'DEBUG GROUPING: Previous: sender=${previousMessage.senderId}, time=${previousMessage.time}',
);
print('DEBUG GROUPING: isGrouped=$isGrouped');
}
final isFirstInGroup =
previousMessage == null ||
!_isMessageGrouped(currentMessage, previousMessage);
final isLastInGroup =
i == source.length - 1 ||
!_isMessageGrouped(source[i + 1], currentMessage);
print(
'DEBUG GROUPING: isFirstInGroup=$isFirstInGroup, isLastInGroup=$isLastInGroup',
);
items.add(
MessageItem(
currentMessage,
isFirstInGroup: isFirstInGroup,
isLastInGroup: isLastInGroup,
isGrouped: isGrouped,
),
);
}
_chatItems = items;
}
void _addMessage(Message message) {
if (_messages.any((m) => m.id == message.id)) {
print('Сообщение ${message.id} уже существует, пропускаем добавление');
return;
}
ApiService.instance.clearCacheForChat(widget.chatId);
final lastMessage = _messages.isNotEmpty ? _messages.last : null;
_messages.add(message);
final currentDate = DateTime.fromMillisecondsSinceEpoch(
message.time,
).toLocal();
final lastDate = lastMessage != null
? DateTime.fromMillisecondsSinceEpoch(lastMessage.time).toLocal()
: null;
if (lastMessage == null || !_isSameDay(currentDate, lastDate!)) {
final separator = DateSeparatorItem(currentDate);
_chatItems.add(separator);
}
final lastMessageItem =
_chatItems.isNotEmpty && _chatItems.last is MessageItem
? _chatItems.last as MessageItem
: null;
final isGrouped = _isMessageGrouped(message, lastMessageItem?.message);
final isFirstInGroup = lastMessageItem == null || !isGrouped;
final isLastInGroup = true;
final messageItem = MessageItem(
message,
isFirstInGroup: isFirstInGroup,
isLastInGroup: isLastInGroup,
isGrouped: isGrouped,
);
_chatItems.add(messageItem);
final theme = context.read<ThemeProvider>();
if (theme.messageTransition == TransitionOption.slide) {
print('Добавлено новое сообщение для анимации Slide+: ${message.id}');
} else {
_animatedMessageIds.add(message.id);
}
if (mounted) {
setState(() {});
}
}
void _updateMessageReaction(
String messageId,
Map<String, dynamic> reactionInfo,
) {
final messageIndex = _messages.indexWhere((m) => m.id == messageId);
if (messageIndex != -1) {
final message = _messages[messageIndex];
final updatedMessage = message.copyWith(reactionInfo: reactionInfo);
_messages[messageIndex] = updatedMessage;
_buildChatItems();
print('Обновлена реакция для сообщения $messageId: $reactionInfo');
if (mounted) {
setState(() {});
}
}
}
void _updateReactionOptimistically(String messageId, String emoji) {
final messageIndex = _messages.indexWhere((m) => m.id == messageId);
if (messageIndex != -1) {
final message = _messages[messageIndex];
final currentReactionInfo = message.reactionInfo ?? {};
final currentCounters = List<Map<String, dynamic>>.from(
currentReactionInfo['counters'] ?? [],
);
final existingCounterIndex = currentCounters.indexWhere(
(counter) => counter['reaction'] == emoji,
);
if (existingCounterIndex != -1) {
currentCounters[existingCounterIndex]['count'] =
(currentCounters[existingCounterIndex]['count'] as int) + 1;
} else {
currentCounters.add({'reaction': emoji, 'count': 1});
}
final updatedReactionInfo = {
...currentReactionInfo,
'counters': currentCounters,
'yourReaction': emoji,
'totalCount': currentCounters.fold<int>(
0,
(sum, counter) => sum + (counter['count'] as int),
),
};
final updatedMessage = message.copyWith(
reactionInfo: updatedReactionInfo,
);
_messages[messageIndex] = updatedMessage;
_buildChatItems();
print('Оптимистично добавлена реакция $emoji к сообщению $messageId');
if (mounted) {
setState(() {});
}
}
}
void _removeReactionOptimistically(String messageId) {
final messageIndex = _messages.indexWhere((m) => m.id == messageId);
if (messageIndex != -1) {
final message = _messages[messageIndex];
final currentReactionInfo = message.reactionInfo ?? {};
final yourReaction = currentReactionInfo['yourReaction'] as String?;
if (yourReaction != null) {
final currentCounters = List<Map<String, dynamic>>.from(
currentReactionInfo['counters'] ?? [],
);
final counterIndex = currentCounters.indexWhere(
(counter) => counter['reaction'] == yourReaction,
);
if (counterIndex != -1) {
final currentCount = currentCounters[counterIndex]['count'] as int;
if (currentCount > 1) {
currentCounters[counterIndex]['count'] = currentCount - 1;
} else {
currentCounters.removeAt(counterIndex);
}
}
final updatedReactionInfo = {
...currentReactionInfo,
'counters': currentCounters,
'yourReaction': null,
'totalCount': currentCounters.fold<int>(
0,
(sum, counter) => sum + (counter['count'] as int),
),
};
final updatedMessage = message.copyWith(
reactionInfo: updatedReactionInfo,
);
_messages[messageIndex] = updatedMessage;
_buildChatItems();
print('Оптимистично удалена реакция с сообщения $messageId');
if (mounted) {
setState(() {});
}
}
}
}
void _updateMessage(Message updatedMessage) {
final index = _messages.indexWhere((m) => m.id == updatedMessage.id);
if (index != -1) {
print(
'Обновляем сообщение ${updatedMessage.id}: "${_messages[index].text}" -> "${updatedMessage.text}"',
);
final oldMessage = _messages[index];
final finalMessage = updatedMessage.link != null
? updatedMessage
: updatedMessage.copyWith(link: oldMessage.link);
print('Обновляем link: ${oldMessage.link} -> ${finalMessage.link}');
_messages[index] = finalMessage;
ApiService.instance.clearCacheForChat(widget.chatId);
_buildChatItems();
setState(() {});
} else {
print(
'Сообщение ${updatedMessage.id} не найдено для обновления. Запрашиваем свежую историю...',
);
ApiService.instance
.getMessageHistory(widget.chatId, force: true)
.then((fresh) {
if (!mounted) return;
_messages
..clear()
..addAll(fresh);
_buildChatItems();
setState(() {});
})
.catchError((_) {});
}
}
void _removeMessages(List<String> messageIds) {
print('Удаляем сообщения: $messageIds');
final removedCount = _messages.length;
_messages.removeWhere((message) => messageIds.contains(message.id));
final actuallyRemoved = removedCount - _messages.length;
print('Удалено сообщений: $actuallyRemoved');
if (actuallyRemoved > 0) {
ApiService.instance.clearCacheForChat(widget.chatId);
_buildChatItems();
setState(() {});
}
}
Future<void> _sendMessage() async {
final text = _textController.text.trim();
if (text.isNotEmpty) {
final theme = context.read<ThemeProvider>();
final isBlocked = _currentContact.isBlockedByMe && !theme.blockBypass;
if (isBlocked) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'Нельзя отправить сообщение заблокированному пользователю',
),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
return;
}
final int tempCid = DateTime.now().millisecondsSinceEpoch;
final tempMessageJson = {
'id': 'local_$tempCid', // Временный "локальный" ID
'text': text,
'time': tempCid,
'sender': _actualMyId!,
'cid': tempCid, // Уникальный ID клиента
'type': 'USER',
'attaches': [], // Оптимистично без вложений (для текста)
'link': _replyingToMessage != null
? {
'type': 'REPLY',
'messageId': _replyingToMessage!.id,
'message': {
'sender': _replyingToMessage!.senderId,
'id': _replyingToMessage!.id,
'time': _replyingToMessage!.time,
'text': _replyingToMessage!.text,
'type': 'USER',
'cid': _replyingToMessage!.cid,
'attaches': _replyingToMessage!.attaches,
},
'chatId': 0, // Не используется, но нужно для парсинга
}
: null,
};
final tempMessage = Message.fromJson(tempMessageJson);
_addMessage(tempMessage);
print(
'Создано временное сообщение с link: ${tempMessage.link} и cid: $tempCid',
);
ApiService.instance.sendMessage(
widget.chatId,
text,
replyToMessageId: _replyingToMessage?.id,
cid: tempCid, // Передаем тот же CID в API
);
final readSettings = await ChatReadSettingsService.instance.getSettings(
widget.chatId,
);
final shouldReadOnAction = readSettings != null
? (!readSettings.disabled && readSettings.readOnAction)
: theme.debugReadOnAction;
if (shouldReadOnAction && _messages.isNotEmpty) {
final lastMessageId = _messages.last.id;
ApiService.instance.markMessageAsRead(widget.chatId, lastMessageId);
}
_textController.clear();
setState(() {
_replyingToMessage = null;
});
widget.onChatUpdated?.call();
}
}
void _testSlideAnimation() {
print('=== ТЕСТ SLIDE+ АНИМАЦИИ ===');
final myMessage = Message(
id: 'test_my_${DateTime.now().millisecondsSinceEpoch}',
text: 'Тест моё сообщение (должно выехать справа)',
time: DateTime.now().millisecondsSinceEpoch,
senderId: _actualMyId!,
);
_addMessage(myMessage);
Future.delayed(const Duration(seconds: 1), () {
final otherMessage = Message(
id: 'test_other_${DateTime.now().millisecondsSinceEpoch}',
text: 'Тест сообщение собеседника (должно выехать слева)',
time: DateTime.now().millisecondsSinceEpoch,
senderId: widget.contact.id,
);
_addMessage(otherMessage);
});
}
void _editMessage(Message message) {
if (!message.canEdit(_actualMyId!)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
message.isDeleted
? 'Удаленное сообщение нельзя редактировать'
: message.attaches.isNotEmpty
? 'Сообщения с вложениями нельзя редактировать'
: 'Сообщение можно редактировать только в течение 24 часов',
),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
return;
}
showDialog(
context: context,
builder: (context) => _EditMessageDialog(
initialText: message.text,
onSave: (newText) async {
if (newText.trim().isNotEmpty && newText != message.text) {
final optimistic = message.copyWith(
text: newText.trim(),
status: 'EDITED',
updateTime: DateTime.now().millisecondsSinceEpoch,
);
_updateMessage(optimistic);
try {
await ApiService.instance.editMessage(
widget.chatId,
message.id,
newText.trim(),
);
widget.onChatUpdated?.call();
} catch (e) {
print('Ошибка при редактировании сообщения: $e');
_updateMessage(message);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка редактирования: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
},
),
);
}
void _replyToMessage(Message message) {
setState(() {
_replyingToMessage = message;
});
_textController.clear();
FocusScope.of(context).requestFocus(FocusNode());
}
void _forwardMessage(Message message) {
_showForwardDialog(message);
}
void _showForwardDialog(Message message) {
final chatData = ApiService.instance.lastChatsPayload;
if (chatData == null || chatData['chats'] == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Список чатов не загружен'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
final chats = chatData['chats'] as List<dynamic>;
final availableChats = chats
.where(
(chat) => chat['id'] != widget.chatId || chat['id'] == 0,
) //шелуха обработка избранного
.toList();
if (availableChats.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Нет доступных чатов для пересылки'),
behavior: SnackBarBehavior.floating,
),
);
return;
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Переслать сообщение'),
content: SizedBox(
width: double.maxFinite,
height: 400,
child: ListView.builder(
itemCount: availableChats.length,
itemBuilder: (context, index) {
final chat = availableChats[index] as Map<String, dynamic>;
return _buildForwardChatTile(context, chat, message);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
],
),
);
}
Widget _buildForwardChatTile(
BuildContext context,
Map<String, dynamic> chat,
Message message,
) {
final chatId = chat['id'] as int;
final chatTitle = chat['title'] as String?;
// шелуха отдельная для избранного
String chatName;
Widget avatar;
String subtitle = '';
if (chatId == 0) {
chatName = 'Избранное';
avatar = CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.star,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
);
subtitle = 'Сохраненные сообщения';
} else {
final participants = chat['participants'] as Map<String, dynamic>? ?? {};
final isGroupChat = participants.length > 2;
if (isGroupChat) {
chatName = chatTitle?.isNotEmpty == true ? chatTitle! : 'Группа';
avatar = CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.group,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
);
subtitle = '${participants.length} участников';
} else {
final otherParticipantId = participants.keys
.map((id) => int.parse(id))
.firstWhere((id) => id != _actualMyId, orElse: () => 0);
final contact = _contactDetailsCache[otherParticipantId];
chatName = contact?.name ?? chatTitle ?? 'Чат $chatId';
final avatarUrl = contact?.photoBaseUrl;
avatar = AvatarCacheService().getAvatarWidget(
avatarUrl,
userId: otherParticipantId,
size: 48,
fallbackText: contact?.name ?? chatTitle ?? 'Чат $chatId',
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
);
subtitle = contact?.status ?? '';
}
}
return Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surface,
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
width: 1,
),
),
child: ClipOval(child: avatar),
),
title: Text(
chatName,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 16,
color: Theme.of(context).colorScheme.onSurface,
),
),
subtitle: subtitle.isNotEmpty
? Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
: null,
trailing: Icon(
Icons.arrow_forward_ios,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
onTap: () {
Navigator.of(context).pop();
_performForward(message, chatId);
},
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
void _performForward(Message message, int targetChatId) {
ApiService.instance.forwardMessage(targetChatId, message.id, widget.chatId);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Сообщение переслано'),
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: 2),
),
);
}
void _cancelReply() {
setState(() {
_replyingToMessage = null;
});
}
void _showBlockDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Заблокировать контакт'),
content: Text(
'Вы уверены, что хотите заблокировать ${_currentContact.name}?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () async {
Navigator.of(context).pop();
try {
await ApiService.instance.blockContact(widget.contact.id);
if (mounted) {
setState(() {
_currentContact = Contact(
id: _currentContact.id,
name: _currentContact.name,
firstName: _currentContact.firstName,
lastName: _currentContact.lastName,
description: _currentContact.description,
photoBaseUrl: _currentContact.photoBaseUrl,
isBlocked: _currentContact.isBlocked,
isBlockedByMe: true,
accountStatus: _currentContact.accountStatus,
status: _currentContact.status,
);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Контакт заблокирован'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка блокировки: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
},
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Заблокировать'),
),
],
),
);
}
void _showUnblockDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Разблокировать контакт'),
content: Text(
'Вы уверены, что хотите разблокировать ${_currentContact.name}?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () async {
Navigator.of(context).pop();
try {
await ApiService.instance.unblockContact(widget.contact.id);
if (mounted) {
setState(() {
_currentContact = Contact(
id: _currentContact.id,
name: _currentContact.name,
firstName: _currentContact.firstName,
lastName: _currentContact.lastName,
description: _currentContact.description,
photoBaseUrl: _currentContact.photoBaseUrl,
isBlocked: _currentContact.isBlocked,
isBlockedByMe: false,
accountStatus: _currentContact.accountStatus,
status: _currentContact.status,
);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Контакт разблокирован'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка разблокировки: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
},
style: FilledButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
child: const Text('Разблокировать'),
),
],
),
);
}
void _showWallpaperDialog() {
showDialog(
context: context,
builder: (context) => _WallpaperSelectionDialog(
chatId: widget.chatId,
onImageSelected: (imagePath) async {
Navigator.of(context).pop();
await _setChatWallpaper(imagePath);
},
onRemoveWallpaper: () async {
Navigator.of(context).pop();
await _removeChatWallpaper();
},
),
);
}
void _showClearHistoryDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Очистить историю чата'),
content: Text(
'Вы уверены, что хотите очистить историю чата с ${_currentContact.name}? Это действие нельзя отменить.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () async {
Navigator.of(context).pop();
try {
await ApiService.instance.clearChatHistory(widget.chatId);
if (mounted) {
setState(() {
_messages.clear();
_chatItems.clear();
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('История чата очищена'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка очистки истории: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
},
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Очистить'),
),
],
),
);
}
void _showDeleteChatDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Удалить чат'),
content: Text(
'Вы уверены, что хотите удалить чат с ${_currentContact.name}? Это действие нельзя отменить.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () async {
Navigator.of(context).pop();
try {
print('Имитация удаления чата ID: ${widget.chatId}');
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
Navigator.of(context).pop();
widget.onChatUpdated?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Чат удален'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка удаления чата: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
},
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Удалить'),
),
],
),
);
}
void _toggleNotifications() {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Уведомления для этого чата выключены')),
);
setState(() {});
}
void _showLeaveGroupDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Выйти из группы'),
content: Text(
'Вы уверены, что хотите выйти из группы "${widget.contact.name}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop(); // Закрываем диалог подтверждения
try {
ApiService.instance.leaveGroup(widget.chatId);
if (mounted) {
Navigator.of(context).pop();
widget.onChatUpdated?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Вы вышли из группы'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка при выходе из группы: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
},
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Выйти'),
),
],
),
);
}
Map<String, dynamic>? _getCurrentGroupChat() {
final chatData = ApiService.instance.lastChatsPayload;
if (chatData == null || chatData['chats'] == null) return null;
final chats = chatData['chats'] as List<dynamic>;
try {
return chats.firstWhere(
(chat) => chat['id'] == widget.chatId,
orElse: () => null,
);
} catch (e) {
return null;
}
}
Future<void> _setChatWallpaper(String imagePath) async {
try {
final theme = context.read<ThemeProvider>();
await theme.setChatSpecificWallpaper(widget.chatId, imagePath);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Обои для чата установлены'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка установки обоев: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
Future<void> _removeChatWallpaper() async {
try {
final theme = context.read<ThemeProvider>();
await theme.setChatSpecificWallpaper(widget.chatId, null);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Обои для чата удалены'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка удаления обоев: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
Future<void> _loadCachedContacts() async {
final cachedContacts = await ChatCacheService().getCachedContacts();
if (cachedContacts != null && cachedContacts.isNotEmpty) {
for (final contact in cachedContacts) {
_contactDetailsCache[contact.id] = contact;
}
print(
'✅ Кэш контактов для экрана чата заполнен из ChatCacheService: ${_contactDetailsCache.length} контактов.',
);
}
}
@override
Widget build(BuildContext context) {
final theme = context.watch<ThemeProvider>();
return Scaffold(
extendBodyBehindAppBar: theme.useGlassPanels,
appBar: _buildAppBar(),
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<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
);
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();
},
),
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()),
],
),
);
}
void _showContactProfile() {
Navigator.of(context).push(
PageRouteBuilder(
opaque: false,
barrierColor: Colors.transparent,
pageBuilder: (context, animation, secondaryAnimation) {
return ContactProfileDialog(
contact: widget.contact,
isChannel: widget.isChannel,
);
},
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 300),
),
);
}
AppBar _buildAppBar() {
final theme = context.watch<ThemeProvider>();
if (_isSearching) {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _stopSearch,
tooltip: 'Закрыть поиск',
),
title: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Поиск по сообщениям...',
border: InputBorder.none,
),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
actions: [
if (_searchResults.isNotEmpty)
Center(
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Text(
'${_currentResultIndex + 1} из ${_searchResults.length}',
),
),
),
IconButton(
icon: const Icon(Icons.keyboard_arrow_up),
onPressed: _searchResults.isNotEmpty ? _navigateToNextResult : null,
tooltip: 'Следующий (более старый) результат',
),
IconButton(
icon: const Icon(Icons.keyboard_arrow_down),
onPressed: _searchResults.isNotEmpty
? _navigateToPreviousResult
: null,
tooltip: 'Предыдущий (более новый) результат',
),
],
);
}
return AppBar(
titleSpacing: 4.0,
backgroundColor: theme.useGlassPanels ? Colors.transparent : null,
elevation: theme.useGlassPanels ? 0 : null,
flexibleSpace: theme.useGlassPanels
? ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: theme.topBarBlur,
sigmaY: theme.topBarBlur,
),
child: Container(
color: Theme.of(
context,
).colorScheme.surface.withOpacity(theme.topBarOpacity),
),
),
)
: null,
leading: widget.isDesktopMode
? null // В десктопном режиме нет кнопки "Назад"
: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
actions: [
if (widget.isGroupChat)
IconButton(
onPressed: () {
if (_actualMyId == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => GroupSettingsScreen(
chatId: widget.chatId,
initialContact: _currentContact,
myId: _actualMyId!,
onChatUpdated: widget.onChatUpdated,
),
),
);
},
icon: const Icon(Icons.settings),
tooltip: 'Настройки группы',
),
PopupMenuButton<String>(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onSelected: (value) {
if (value == 'search') {
_startSearch();
} else if (value == 'block') {
_showBlockDialog();
} else if (value == 'unblock') {
_showUnblockDialog();
} else if (value == 'wallpaper') {
_showWallpaperDialog();
} else if (value == 'toggle_notifications') {
_toggleNotifications();
} else if (value == 'clear_history') {
_showClearHistoryDialog();
} else if (value == 'delete_chat') {
_showDeleteChatDialog();
} else if (value == 'leave_group' || value == 'leave_channel') {
_showLeaveGroupDialog();
}
},
itemBuilder: (context) {
bool amIAdmin = false;
if (widget.isGroupChat) {
final currentChat = _getCurrentGroupChat();
if (currentChat != null) {
final admins = currentChat['admins'] as List<dynamic>? ?? [];
if (_actualMyId != null) {
amIAdmin = admins.contains(_actualMyId);
}
}
}
final bool canDeleteChat = !widget.isGroupChat || amIAdmin;
return [
const PopupMenuItem(
value: 'search',
child: Row(
children: [
Icon(Icons.search),
SizedBox(width: 8),
Text('Поиск'),
],
),
),
const PopupMenuItem(
value: 'wallpaper',
child: Row(
children: [
Icon(Icons.wallpaper),
SizedBox(width: 8),
Text('Обои'),
],
),
),
if (!widget.isGroupChat && !widget.isChannel) ...[
if (_currentContact.isBlockedByMe)
const PopupMenuItem(
value: 'unblock',
child: Row(
children: [
Icon(Icons.person_add, color: Colors.green),
SizedBox(width: 8),
Text('Разблокировать'),
],
),
)
else
const PopupMenuItem(
value: 'block',
child: Row(
children: [
Icon(Icons.block, color: Colors.red),
SizedBox(width: 8),
Text('Заблокировать'),
],
),
),
],
PopupMenuItem(
value: 'toggle_notifications',
child: Row(
children: [
Icon(Icons.notifications),
SizedBox(width: 8),
Text('Выкл. уведомления'),
],
),
),
const PopupMenuDivider(),
if (!widget.isChannel)
const PopupMenuItem(
value: 'clear_history',
child: Row(
children: [
Icon(Icons.clear_all, color: Colors.orange),
SizedBox(width: 8),
Text('Очистить историю'),
],
),
),
if (widget.isGroupChat)
const PopupMenuItem(
value: 'leave_group',
child: Row(
children: [
Icon(Icons.exit_to_app, color: Colors.red),
SizedBox(width: 8),
Text('Выйти из группы'),
],
),
),
if (widget.isChannel)
const PopupMenuItem(
value: 'leave_channel', // Новое значение
child: Row(
children: [
Icon(Icons.exit_to_app, color: Colors.red),
SizedBox(width: 8),
Text('Покинуть канал'), // Другой текст
],
),
),
if (canDeleteChat && !widget.isChannel)
const PopupMenuItem(
value: 'delete_chat',
child: Row(
children: [
Icon(Icons.delete_forever, color: Colors.red),
SizedBox(width: 8),
Text('Удалить чат'),
],
),
),
];
},
),
],
title: Row(
children: [
GestureDetector(
onTap: _showContactProfile,
child: Hero(
tag: 'contact_avatar_${widget.contact.id}',
child: widget.chatId == 0
? CircleAvatar(
radius: 18,
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
child: Icon(
Icons.bookmark,
size: 20,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
)
: CircleAvatar(
radius: 18,
backgroundImage: widget.contact.photoBaseUrl != null
? NetworkImage(widget.contact.photoBaseUrl!)
: null,
child: widget.contact.photoBaseUrl == null
? Text(
widget.contact.name.isNotEmpty
? widget.contact.name[0].toUpperCase()
: '?',
)
: null,
),
),
),
const SizedBox(width: 8),
Expanded(
child: GestureDetector(
onTap: _showContactProfile,
behavior: HitTestBehavior.opaque,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
widget.contact.name,
overflow: TextOverflow.ellipsis,
),
),
if (context
.watch<ThemeProvider>()
.debugShowMessageCount) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: theme.ultraOptimizeChats
? Colors.red.withOpacity(0.7)
: theme.optimizeChats
? Colors.orange.withOpacity(0.7)
: Colors.blue.withOpacity(0.7),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${_messages.length}${theme.ultraOptimizeChats
? 'U'
: theme.optimizeChats
? 'O'
: ''}',
style: const TextStyle(
fontSize: 11,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
const SizedBox(height: 2),
if (widget.isGroupChat ||
widget.isChannel) // Объединенное условие
Text(
widget.isChannel
? "${widget.participantCount ?? 0} подписчиков"
: "${widget.participantCount ?? 0} участников",
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
else if (widget.chatId != 0)
_ContactPresenceSubtitle(
chatId: widget.chatId,
userId: widget.contact.id,
),
],
),
),
),
],
),
);
}
Widget _buildChatWallpaper(ThemeProvider provider) {
if (provider.hasChatSpecificWallpaper(widget.chatId)) {
final chatSpecificImagePath = provider.getChatSpecificWallpaper(
widget.chatId,
);
if (chatSpecificImagePath != null) {
return Image.file(
File(chatSpecificImagePath),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
);
}
}
if (!provider.useCustomChatWallpaper) {
return Container(color: Theme.of(context).colorScheme.surface);
}
switch (provider.chatWallpaperType) {
case ChatWallpaperType.solid:
return Container(color: provider.chatWallpaperColor1);
case ChatWallpaperType.gradient:
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
provider.chatWallpaperColor1,
provider.chatWallpaperColor2,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
);
case ChatWallpaperType.image:
final Widget image;
if (provider.chatWallpaperImagePath != null) {
image = Stack(
fit: StackFit.expand,
children: [
Image.file(
File(provider.chatWallpaperImagePath!),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
if (provider.chatWallpaperImageBlur > 0)
BackdropFilter(
filter: ImageFilter.blur(
sigmaX: provider.chatWallpaperImageBlur,
sigmaY: provider.chatWallpaperImageBlur,
),
child: Container(color: Colors.transparent),
),
],
);
} else {
image = Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
);
}
return Stack(
fit: StackFit.expand,
children: [
image,
if (provider.chatWallpaperBlur)
BackdropFilter(
filter: ImageFilter.blur(
sigmaX: provider.chatWallpaperBlurSigma,
sigmaY: provider.chatWallpaperBlurSigma,
),
child: Container(color: Colors.black.withOpacity(0.0)),
),
],
);
case ChatWallpaperType.video:
if (Platform.isWindows) {
return Container(
color: Theme.of(context).colorScheme.surface,
child: Center(
child: Text(
'Видео-обои не поддерживаются\nна Windows',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
),
);
}
if (provider.chatWallpaperVideoPath != null &&
provider.chatWallpaperVideoPath!.isNotEmpty) {
return _VideoWallpaperBackground(
videoPath: provider.chatWallpaperVideoPath!,
);
} else {
return Container(color: Theme.of(context).colorScheme.surface);
}
}
}
Widget _buildTextInput() {
if (widget.isChannel) {
return const SizedBox.shrink(); // Возвращаем пустой виджет для каналов
}
final theme = context.watch<ThemeProvider>();
final isBlocked = _currentContact.isBlockedByMe && !theme.blockBypass;
if (_currentContact.name.toLowerCase() == 'max') {
return const SizedBox.shrink();
}
if (theme.useGlassPanels) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: theme.bottomBarBlur,
sigmaY: theme.bottomBarBlur,
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 12.0,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface.withOpacity(theme.bottomBarOpacity),
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 0.5,
),
),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_replyingToMessage != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.reply,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ответ на сообщение',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.primary,
),
),
const SizedBox(height: 2),
Text(
_replyingToMessage!.text.isNotEmpty
? _replyingToMessage!.text
: 'Фото',
style: TextStyle(
fontSize: 13,
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.8),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
onPressed: _cancelReply,
icon: const Icon(Icons.close),
iconSize: 18,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
],
if (isBlocked) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.errorContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(
context,
).colorScheme.error.withOpacity(0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.block,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Text(
'Пользователь заблокирован',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 4),
Text(
'Разблокируйте пользователя для отправки сообщений',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onErrorContainer,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
'или включите block_bypass',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onErrorContainer,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
),
),
],
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Focus(
focusNode:
_textFocusNode, // 2. focusNode теперь здесь
onKeyEvent: (node, event) {
if (event is KeyDownEvent) {
if (event.logicalKey ==
LogicalKeyboardKey.enter) {
final bool isShiftPressed =
HardwareKeyboard.instance.logicalKeysPressed
.contains(
LogicalKeyboardKey.shiftLeft,
) ||
HardwareKeyboard.instance.logicalKeysPressed
.contains(
LogicalKeyboardKey.shiftRight,
);
if (!isShiftPressed) {
_sendMessage();
return KeyEventResult.handled;
}
}
}
return KeyEventResult.ignored;
},
child: TextField(
controller: _textController,
enabled: !isBlocked,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
minLines: 1,
maxLines: 5,
decoration: InputDecoration(
hintText: isBlocked
? 'Пользователь заблокирован'
: 'Сообщение...',
filled: true,
fillColor: isBlocked
? Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withOpacity(0.3)
: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withOpacity(0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
),
onChanged: isBlocked
? null
: (v) {
if (v.isNotEmpty) {
_scheduleTypingPing();
}
},
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.photo_library_outlined),
tooltip: isBlocked
? 'Пользователь заблокирован'
: 'Отправить фото',
onPressed: isBlocked
? null
: () async {
final result = await _pickPhotosFlow(context);
if (result != null && result.paths.isNotEmpty) {
await ApiService.instance.sendPhotoMessages(
widget.chatId,
localPaths: result.paths,
caption: result.caption,
senderId: _actualMyId,
);
}
},
color: isBlocked
? Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.3)
: Theme.of(context).colorScheme.primary,
),
if (context.watch<ThemeProvider>().messageTransition ==
TransitionOption.slide)
IconButton(
icon: const Icon(Icons.animation),
onPressed: isBlocked ? null : _testSlideAnimation,
color: isBlocked
? Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.3)
: Colors.orange,
tooltip: isBlocked
? 'Пользователь заблокирован'
: 'Тест Slide+ анимации',
),
IconButton(
icon: const Icon(Icons.send),
onPressed: isBlocked ? null : _sendMessage,
color: isBlocked
? Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.3)
: Theme.of(context).colorScheme.primary,
),
],
),
],
),
),
),
),
);
} else {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, // Обычный цвет фона
border: Border(
top: BorderSide(color: Theme.of(context).dividerColor, width: 0.5),
),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_replyingToMessage != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.reply,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ответ на сообщение',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 2),
Text(
_replyingToMessage!.text.isNotEmpty
? _replyingToMessage!.text
: 'Фото',
style: TextStyle(
fontSize: 13,
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.8),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
onPressed: _cancelReply,
icon: const Icon(Icons.close),
iconSize: 18,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
],
if (isBlocked) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.errorContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(
context,
).colorScheme.error.withOpacity(0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.block,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Text(
'Пользователь заблокирован',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 4),
Text(
'Разблокируйте пользователя для отправки сообщений',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
'или включите block_bypass',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
),
),
],
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _textController,
enabled: !isBlocked,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
minLines: 1,
maxLines: 5,
decoration: InputDecoration(
hintText: isBlocked
? 'Пользователь заблокирован'
: 'Сообщение...',
filled: true,
fillColor: isBlocked
? Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withOpacity(0.3)
: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withOpacity(0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
),
onChanged: isBlocked
? null
: (v) {
if (v.isNotEmpty) {
_scheduleTypingPing();
}
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.photo_library_outlined),
tooltip: isBlocked
? 'Пользователь заблокирован'
: 'Отправить фото',
onPressed: isBlocked
? null
: () async {
final result = await _pickPhotosFlow(context);
if (result != null && result.paths.isNotEmpty) {
await ApiService.instance.sendPhotoMessages(
widget.chatId,
localPaths: result.paths,
caption: result.caption,
senderId: _actualMyId,
);
}
},
color: isBlocked
? Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.3)
: Theme.of(context).colorScheme.primary,
),
if (context.watch<ThemeProvider>().messageTransition ==
TransitionOption.slide)
IconButton(
icon: const Icon(Icons.animation),
onPressed: isBlocked ? null : _testSlideAnimation,
color: isBlocked
? Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.3)
: Colors.orange,
tooltip: isBlocked
? 'Пользователь заблокирован'
: 'Тест Slide+ анимации',
),
IconButton(
icon: const Icon(Icons.send),
onPressed: isBlocked ? null : _sendMessage,
color: isBlocked
? Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.3)
: Theme.of(context).colorScheme.primary,
),
],
),
],
),
),
);
}
}
Timer? _typingTimer;
DateTime _lastTypingSentAt = DateTime.fromMillisecondsSinceEpoch(0);
void _scheduleTypingPing() {
final now = DateTime.now();
if (now.difference(_lastTypingSentAt) >= const Duration(seconds: 9)) {
ApiService.instance.sendTyping(widget.chatId, type: "TEXT");
_lastTypingSentAt = now;
}
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 9), () {
if (!mounted) return;
if (_textController.text.isNotEmpty) {
ApiService.instance.sendTyping(widget.chatId, type: "TEXT");
_lastTypingSentAt = DateTime.now();
_scheduleTypingPing();
}
});
}
@override
void dispose() {
_typingTimer?.cancel();
_apiSubscription?.cancel();
_textController.dispose();
_textFocusNode.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
void _startSearch() {
setState(() {
_isSearching = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
}
void _stopSearch() {
setState(() {
_isSearching = false;
_searchResults.clear();
_currentResultIndex = -1;
_searchController.clear();
_messageKeys.clear();
});
}
void _performSearch(String query) {
if (query.isEmpty) {
if (_searchResults.isNotEmpty) {
setState(() {
_searchResults.clear();
_currentResultIndex = -1;
});
}
return;
}
final results = _messages
.where((msg) => msg.text.toLowerCase().contains(query.toLowerCase()))
.toList();
setState(() {
_searchResults = results.reversed.toList();
_currentResultIndex = _searchResults.isNotEmpty ? 0 : -1;
});
if (_currentResultIndex != -1) {
_scrollToResult();
}
}
void _navigateToNextResult() {
if (_searchResults.isEmpty) return;
setState(() {
_currentResultIndex = (_currentResultIndex + 1) % _searchResults.length;
});
_scrollToResult();
}
void _navigateToPreviousResult() {
if (_searchResults.isEmpty) return;
setState(() {
_currentResultIndex =
(_currentResultIndex - 1 + _searchResults.length) %
_searchResults.length;
});
_scrollToResult();
}
void _scrollToResult() {
if (_currentResultIndex == -1) return;
final targetMessage = _searchResults[_currentResultIndex];
final itemIndex = _chatItems.indexWhere(
(item) => item is MessageItem && item.message.id == targetMessage.id,
);
if (itemIndex != -1) {
final viewIndex = _chatItems.length - 1 - itemIndex;
_itemScrollController.scrollTo(
index: viewIndex,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
alignment: 0.5,
);
}
}
void _scrollToMessage(String messageId) {
final itemIndex = _chatItems.indexWhere(
(item) => item is MessageItem && item.message.id == messageId,
);
if (itemIndex != -1) {
final viewIndex = _chatItems.length - 1 - itemIndex;
_itemScrollController.scrollTo(
index: viewIndex,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
alignment: 0.5,
);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Исходное сообщение не найдено (возможно, оно в старой истории)',
),
),
);
}
}
}
}
class _EditMessageDialog extends StatefulWidget {
final String initialText;
final Function(String) onSave;
const _EditMessageDialog({required this.initialText, required this.onSave});
@override
State<_EditMessageDialog> createState() => _EditMessageDialogState();
}
class _EditMessageDialogState extends State<_EditMessageDialog> {
late TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialText);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Редактировать сообщение'),
content: TextField(
controller: _controller,
maxLines: 3,
decoration: const InputDecoration(
hintText: 'Введите текст сообщения',
border: OutlineInputBorder(),
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
TextButton(
onPressed: () {
widget.onSave(_controller.text);
Navigator.pop(context);
},
child: const Text('Сохранить'),
),
],
);
}
}
class _ContactPresenceSubtitle extends StatefulWidget {
final int chatId;
final int userId;
const _ContactPresenceSubtitle({required this.chatId, required this.userId});
@override
State<_ContactPresenceSubtitle> createState() =>
_ContactPresenceSubtitleState();
}
class _ContactPresenceSubtitleState extends State<_ContactPresenceSubtitle> {
String _status = 'был(а) недавно';
Timer? _typingDecayTimer;
bool _isOnline = false;
DateTime? _lastSeen;
StreamSubscription? _sub;
@override
void initState() {
super.initState();
final lastSeen = ApiService.instance.getLastSeen(widget.userId);
if (lastSeen != null) {
_lastSeen = lastSeen;
_status = _formatLastSeen(_lastSeen);
}
_sub = ApiService.instance.messages.listen((msg) {
try {
final int? opcode = msg['opcode'];
final payload = msg['payload'];
if (opcode == 129) {
final dynamic incomingChatId = payload['chatId'];
final int? cid = incomingChatId is int
? incomingChatId
: int.tryParse(incomingChatId?.toString() ?? '');
if (cid == widget.chatId) {
setState(() => _status = 'печатает…');
_typingDecayTimer?.cancel();
_typingDecayTimer = Timer(const Duration(seconds: 11), () {
if (!mounted) return;
if (_status == 'печатает…') {
setState(() {
if (_isOnline) {
_status = 'онлайн';
} else {
_status = _formatLastSeen(_lastSeen);
}
});
}
});
}
} else if (opcode == 132) {
final dynamic incomingChatId = payload['chatId'];
final int? cid = incomingChatId is int
? incomingChatId
: int.tryParse(incomingChatId?.toString() ?? '');
if (cid == widget.chatId) {
final bool isOnline = payload['online'] == true;
if (!mounted) return;
_isOnline = isOnline;
setState(() {
if (_status != 'печатает…') {
if (_isOnline) {
_status = 'онлайн';
} else {
final updatedLastSeen = ApiService.instance.getLastSeen(
widget.userId,
);
if (updatedLastSeen != null) {
_lastSeen = updatedLastSeen;
} else {
_lastSeen = DateTime.now();
}
_status = _formatLastSeen(_lastSeen);
}
}
});
}
}
} catch (_) {}
});
}
String _formatLastSeen(DateTime? lastSeen) {
if (lastSeen == null) return 'был(а) недавно';
final now = DateTime.now();
final difference = now.difference(lastSeen);
String timeAgo;
if (difference.inMinutes < 1) {
timeAgo = 'только что';
} else if (difference.inMinutes < 60) {
timeAgo = '${difference.inMinutes} мин. назад';
} else if (difference.inHours < 24) {
timeAgo = '${difference.inHours} ч. назад';
} else if (difference.inDays < 7) {
timeAgo = '${difference.inDays} дн. назад';
} else {
final day = lastSeen.day.toString().padLeft(2, '0');
final month = lastSeen.month.toString().padLeft(2, '0');
timeAgo = '$day.$month.${lastSeen.year}';
}
if (_debugShowExactDate) {
final formatter = DateFormat('dd.MM.yyyy HH:mm:ss');
return '$timeAgo (${formatter.format(lastSeen)})';
}
return timeAgo;
}
@override
void dispose() {
_typingDecayTimer?.cancel();
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
String displayStatus;
if (_status == 'печатает…' || _status == 'онлайн') {
displayStatus = _status;
} else if (_isOnline) {
displayStatus = 'онлайн';
} else {
displayStatus = _formatLastSeen(_lastSeen);
}
return GestureDetector(
onLongPress: () {
toggleDebugExactDate();
if (mounted) {
setState(() {});
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayStatus,
style: style,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (_debugShowExactDate) ...[
const SizedBox(width: 4),
Icon(
Icons.bug_report,
size: 12,
color: Theme.of(context).colorScheme.primary,
),
],
],
),
);
}
}
class _PhotosToSend {
final List<String> paths;
final String caption;
const _PhotosToSend({required this.paths, required this.caption});
}
class _SendPhotosDialog extends StatefulWidget {
const _SendPhotosDialog();
@override
State<_SendPhotosDialog> createState() => _SendPhotosDialogState();
}
class _SendPhotosDialogState extends State<_SendPhotosDialog> {
final TextEditingController _caption = TextEditingController();
final List<String> _pickedPaths = [];
final List<ImageProvider?> _previews = [];
@override
void dispose() {
_caption.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Отправить фото'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _caption,
maxLines: 2,
decoration: const InputDecoration(
hintText: 'Подпись (необязательно)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () async {
try {
final imgs = await ImagePicker().pickMultiImage(
imageQuality: 100,
);
if (imgs.isNotEmpty) {
_pickedPaths
..clear()
..addAll(imgs.map((e) => e.path));
_previews
..clear()
..addAll(imgs.map((e) => FileImage(File(e.path))));
setState(() {});
}
} catch (_) {}
},
icon: const Icon(Icons.photo_library),
label: Text(
_pickedPaths.isEmpty
? 'Выбрать фото'
: 'Выбрано: ${_pickedPaths.length}',
),
),
if (_pickedPaths.isNotEmpty) ...[
const SizedBox(height: 12),
SizedBox(
width: 320,
height: 220,
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 6,
crossAxisSpacing: 6,
),
itemCount: _previews.length,
itemBuilder: (ctx, i) {
final preview = _previews[i];
return Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: preview != null
? Image(image: preview, fit: BoxFit.cover)
: const ColoredBox(color: Colors.black12),
),
Positioned(
right: 4,
top: 4,
child: GestureDetector(
onTap: () {
setState(() {
_previews.removeAt(i);
_pickedPaths.removeAt(i);
});
},
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(2),
child: const Icon(
Icons.close,
size: 16,
color: Colors.white,
),
),
),
),
],
);
},
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
TextButton(
onPressed: _pickedPaths.isEmpty
? null
: () {
Navigator.pop(
context,
_PhotosToSend(paths: _pickedPaths, caption: _caption.text),
);
},
child: const Text('Отправить'),
),
],
);
}
}
Future<_PhotosToSend?> _pickPhotosFlow(BuildContext context) async {
final isMobile =
Theme.of(context).platform == TargetPlatform.android ||
Theme.of(context).platform == TargetPlatform.iOS;
if (isMobile) {
return await showModalBottomSheet<_PhotosToSend>(
context: context,
isScrollControlled: true,
builder: (ctx) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: const _SendPhotosBottomSheet(),
),
);
} else {
return await showDialog<_PhotosToSend>(
context: context,
builder: (ctx) => const _SendPhotosDialog(),
);
}
}
class _SendPhotosBottomSheet extends StatefulWidget {
const _SendPhotosBottomSheet();
@override
State<_SendPhotosBottomSheet> createState() => _SendPhotosBottomSheetState();
}
class _SendPhotosBottomSheetState extends State<_SendPhotosBottomSheet> {
final TextEditingController _caption = TextEditingController();
final List<String> _pickedPaths = [];
final List<ImageProvider?> _previews = [];
@override
void dispose() {
_caption.dispose();
super.dispose();
}
Future<void> _pickMore() async {
try {
final imgs = await ImagePicker().pickMultiImage(imageQuality: 100);
if (imgs.isNotEmpty) {
_pickedPaths.addAll(imgs.map((e) => e.path));
_previews.addAll(imgs.map((e) => FileImage(File(e.path))));
setState(() {});
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Выбор фото',
style: TextStyle(fontWeight: FontWeight.bold),
),
const Spacer(),
IconButton(
onPressed: _pickMore,
icon: const Icon(Icons.add_photo_alternate_outlined),
),
],
),
if (_pickedPaths.isNotEmpty)
SizedBox(
height: 140,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemBuilder: (c, i) {
final preview = _previews[i];
return Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: preview != null
? Image(
image: preview,
width: 140,
height: 140,
fit: BoxFit.cover,
)
: const ColoredBox(color: Colors.black12),
),
Positioned(
right: 6,
top: 6,
child: GestureDetector(
onTap: () {
setState(() {
_previews.removeAt(i);
_pickedPaths.removeAt(i);
});
},
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(2),
child: const Icon(
Icons.close,
size: 16,
color: Colors.white,
),
),
),
),
],
);
},
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemCount: _previews.length,
),
)
else
OutlinedButton.icon(
onPressed: _pickMore,
icon: const Icon(Icons.photo_library_outlined),
label: const Text('Выбрать фото'),
),
const SizedBox(height: 12),
TextField(
controller: _caption,
maxLines: 2,
decoration: const InputDecoration(
hintText: 'Подпись (необязательно)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Row(
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
const Spacer(),
FilledButton(
onPressed: _pickedPaths.isEmpty
? null
: () {
Navigator.pop(
context,
_PhotosToSend(
paths: _pickedPaths,
caption: _caption.text,
),
);
},
child: const Text('Отправить'),
),
],
),
],
),
),
);
}
}
class _DateSeparatorChip extends StatelessWidget {
final DateTime date;
const _DateSeparatorChip({required this.date});
String _formatDate(DateTime localDate) {
final now = DateTime.now();
if (localDate.year == now.year &&
localDate.month == now.month &&
localDate.day == now.day) {
return 'Сегодня';
}
final yesterday = now.subtract(const Duration(days: 1));
if (localDate.year == yesterday.year &&
localDate.month == yesterday.month &&
localDate.day == yesterday.day) {
return 'Вчера';
}
return DateFormat.yMMMMd('ru').format(localDate);
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.5),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_formatDate(date),
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
),
),
);
}
}
extension BrightnessExtension on Brightness {
bool get isDark => this == Brightness.dark;
}
class GroupProfileDraggableDialog extends StatelessWidget {
final Contact contact;
const GroupProfileDraggableDialog({required this.contact});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.3,
maxChildSize: 1.0,
builder: (context, scrollController) {
return Container(
decoration: BoxDecoration(
color: colors.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: colors.onSurfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Hero(
tag: 'contact_avatar_${contact.id}',
child: CircleAvatar(
radius: 60,
backgroundImage: contact.photoBaseUrl != null
? NetworkImage(contact.photoBaseUrl!)
: null,
child: contact.photoBaseUrl == null
? Text(
contact.name.isNotEmpty
? contact.name[0].toUpperCase()
: '?',
style: const TextStyle(fontSize: 32),
)
: null,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Expanded(
child: Text(
contact.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
IconButton(
icon: Icon(Icons.settings, color: colors.primary),
onPressed: () async {
final myId = 0; // This should be passed or retrieved
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => GroupSettingsScreen(
chatId: -contact
.id, // Convert back to positive chatId
initialContact: contact,
myId: myId,
),
),
);
},
tooltip: 'Настройки группы',
),
],
),
),
const SizedBox(height: 20),
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
if (contact.description != null &&
contact.description!.isNotEmpty)
Text(
contact.description!,
style: TextStyle(
color: colors.onSurfaceVariant,
fontSize: 14,
),
),
],
),
),
],
),
);
},
);
}
}
class ContactProfileDialog extends StatelessWidget {
final Contact contact;
final bool isChannel;
const ContactProfileDialog({required this.contact, this.isChannel = false});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final String nickname = contact.name;
final String description = contact.description ?? '';
final theme = context.watch<ThemeProvider>();
return Dialog.fullscreen(
backgroundColor: Colors.transparent,
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.of(context).pop(),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: theme.profileDialogBlur,
sigmaY: theme.profileDialogBlur,
),
child: Container(
color: Colors.black.withOpacity(theme.profileDialogOpacity),
),
),
),
),
Column(
children: [
Expanded(
child: Center(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(
0,
-0.3 *
(1.0 - value) *
MediaQuery.of(context).size.height *
0.15,
),
child: child,
),
);
},
child: Hero(
tag: 'contact_avatar_${contact.id}',
child: CircleAvatar(
radius: 96,
backgroundImage: contact.photoBaseUrl != null
? NetworkImage(contact.photoBaseUrl!)
: null,
child: contact.photoBaseUrl == null
? Text(
contact.name.isNotEmpty
? contact.name[0].toUpperCase()
: '?',
style: const TextStyle(fontSize: 48),
)
: null,
),
),
),
),
),
Builder(
builder: (context) {
final panel = Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 16,
offset: const Offset(0, -8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
nickname,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 8),
if (description.isNotEmpty)
Linkify(
text: description,
style: TextStyle(
color: colors.onSurfaceVariant,
fontSize: 14,
),
linkStyle: TextStyle(
color: colors.primary, // Цвет ссылки
fontSize: 14,
decoration: TextDecoration.underline,
),
onOpen: (link) async {
final uri = Uri.parse(link.url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Не удалось открыть ссылку: ${link.url}',
),
),
);
}
}
},
)
else
const SizedBox(height: 16),
if (!isChannel)
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Редактирование контакта'),
),
);
},
icon: const Icon(Icons.edit),
label: const Text('Редактировать'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
);
return TweenAnimationBuilder<Offset>(
tween: Tween<Offset>(
begin: const Offset(0, 300),
end: Offset.zero,
),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
builder: (context, offset, child) {
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 200),
curve: Curves.easeIn,
builder: (context, opacity, innerChild) {
return Opacity(
opacity: opacity,
child: Transform.translate(
offset: offset,
child: innerChild,
),
);
},
child: child,
);
},
child: panel,
);
},
),
],
),
],
),
);
}
}
class _WallpaperSelectionDialog extends StatefulWidget {
final int chatId;
final Function(String) onImageSelected;
final VoidCallback onRemoveWallpaper;
const _WallpaperSelectionDialog({
required this.chatId,
required this.onImageSelected,
required this.onRemoveWallpaper,
});
@override
State<_WallpaperSelectionDialog> createState() =>
_WallpaperSelectionDialogState();
}
class _WallpaperSelectionDialogState extends State<_WallpaperSelectionDialog> {
String? _selectedImagePath;
bool _isLoading = false;
@override
Widget build(BuildContext context) {
final theme = context.watch<ThemeProvider>();
final hasExistingWallpaper = theme.hasChatSpecificWallpaper(widget.chatId);
return AlertDialog(
title: const Text('Обои для чата'),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_selectedImagePath != null) ...[
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(_selectedImagePath!),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
),
),
const SizedBox(height: 16),
],
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
onPressed: _isLoading ? null : () => _pickImageFromGallery(),
icon: const Icon(Icons.photo_library),
label: const Text('Галерея'),
),
ElevatedButton.icon(
onPressed: _isLoading ? null : () => _pickImageFromCamera(),
icon: const Icon(Icons.camera_alt),
label: const Text('Камера'),
),
],
),
const SizedBox(height: 16),
if (hasExistingWallpaper)
ElevatedButton.icon(
onPressed: _isLoading ? null : widget.onRemoveWallpaper,
icon: const Icon(Icons.delete),
label: const Text('Удалить обои'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
if (_selectedImagePath != null)
FilledButton(
onPressed: _isLoading
? null
: () => widget.onImageSelected(_selectedImagePath!),
child: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Установить'),
),
],
);
}
Future<void> _pickImageFromGallery() async {
setState(() => _isLoading = true);
try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image != null && mounted) {
setState(() {
_selectedImagePath = image.path;
_isLoading = false;
});
} else if (mounted) {
setState(() => _isLoading = false);
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка выбора фото: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
Future<void> _pickImageFromCamera() async {
setState(() => _isLoading = true);
try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.camera);
if (image != null && mounted) {
setState(() {
_selectedImagePath = image.path;
_isLoading = false;
});
} else if (mounted) {
setState(() => _isLoading = false);
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ошибка съемки фото: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
}
}
class _AddMemberDialog extends StatefulWidget {
final List<Map<String, dynamic>> contacts;
final Function(List<int>) onAddMembers;
const _AddMemberDialog({required this.contacts, required this.onAddMembers});
@override
State<_AddMemberDialog> createState() => _AddMemberDialogState();
}
class _AddMemberDialogState extends State<_AddMemberDialog> {
final Set<int> _selectedContacts = {};
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Добавить участников'),
content: SizedBox(
width: double.maxFinite,
height: 400,
child: ListView.builder(
itemCount: widget.contacts.length,
itemBuilder: (context, index) {
final contact = widget.contacts[index];
final contactId = contact['id'] as int;
final contactName = contact['names']?[0]?['name'] ?? 'Неизвестный';
final isSelected = _selectedContacts.contains(contactId);
return CheckboxListTile(
value: isSelected,
onChanged: (value) {
setState(() {
if (value == true) {
_selectedContacts.add(contactId);
} else {
_selectedContacts.remove(contactId);
}
});
},
title: Text(contactName),
subtitle: Text('ID: $contactId'),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: _selectedContacts.isEmpty
? null
: () => widget.onAddMembers(_selectedContacts.toList()),
child: Text('Добавить (${_selectedContacts.length})'),
),
],
);
}
}
class _RemoveMemberDialog extends StatefulWidget {
final List<Map<String, dynamic>> members;
final Function(List<int>) onRemoveMembers;
const _RemoveMemberDialog({
required this.members,
required this.onRemoveMembers,
});
@override
State<_RemoveMemberDialog> createState() => _RemoveMemberDialogState();
}
class _RemoveMemberDialogState extends State<_RemoveMemberDialog> {
final Set<int> _selectedMembers = {};
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Удалить участников'),
content: SizedBox(
width: double.maxFinite,
height: 400,
child: ListView.builder(
itemCount: widget.members.length,
itemBuilder: (context, index) {
final member = widget.members[index];
final memberId = member['id'] as int;
final memberName = member['name'] as String;
final isSelected = _selectedMembers.contains(memberId);
return CheckboxListTile(
value: isSelected,
onChanged: (value) {
setState(() {
if (value == true) {
_selectedMembers.add(memberId);
} else {
_selectedMembers.remove(memberId);
}
});
},
title: Text(memberName),
subtitle: Text('ID: $memberId'),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: _selectedMembers.isEmpty
? null
: () => widget.onRemoveMembers(_selectedMembers.toList()),
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: Text('Удалить (${_selectedMembers.length})'),
),
],
);
}
}
class _PromoteAdminDialog extends StatelessWidget {
final List<Map<String, dynamic>> members;
final Function(int) onPromoteToAdmin;
const _PromoteAdminDialog({
required this.members,
required this.onPromoteToAdmin,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Назначить администратором'),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: ListView.builder(
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
final memberId = member['id'] as int;
final memberName = member['name'] as String;
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
memberName[0].toUpperCase(),
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
title: Text(memberName),
subtitle: Text('ID: $memberId'),
trailing: const Icon(Icons.admin_panel_settings),
onTap: () => onPromoteToAdmin(memberId),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
],
);
}
}
class _ControlMessageChip extends StatelessWidget {
final Message message;
final Map<int, Contact> contacts; // We need this to get user names by ID
final int myId;
const _ControlMessageChip({
required this.message,
required this.contacts,
required this.myId,
});
String _formatControlMessage() {
final controlAttach = message.attaches.firstWhere(
(a) => a['_type'] == 'CONTROL',
);
final eventType = controlAttach['event'];
final senderName = contacts[message.senderId]?.name ?? 'Неизвестный';
final isMe = message.senderId == myId;
final senderDisplayName = isMe ? 'Вы' : senderName;
String _formatUserList(List<int> userIds) {
if (userIds.isEmpty) {
return '';
}
final userNames = userIds
.map((id) {
if (id == myId) {
return 'Вы';
}
return contacts[id]?.name ?? 'участник с ID $id';
})
.where((name) => name.isNotEmpty)
.join(', ');
return userNames;
}
switch (eventType) {
case 'new':
final title = controlAttach['title'] ?? 'Новая группа';
return '$senderDisplayName создал(а) группу "$title"';
case 'add':
final userIds = List<int>.from(
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
);
if (userIds.isEmpty) {
return 'К чату присоединились новые участники';
}
final userNames = _formatUserList(userIds);
if (userNames.isEmpty) {
return 'К чату присоединились новые участники';
}
return '$senderDisplayName добавил(а) в чат: $userNames';
case 'remove':
case 'kick':
final userIds = List<int>.from(
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
);
if (userIds.isEmpty) {
return '$senderDisplayName удалил(а) участников из чата';
}
final userNames = _formatUserList(userIds);
if (userNames.isEmpty) {
return '$senderDisplayName удалил(а) участников из чата';
}
if (userIds.contains(myId)) {
return 'Вы были удалены из чата';
}
return '$senderDisplayName удалил(а) из чата: $userNames';
case 'leave':
if (isMe) {
return 'Вы покинули группу';
}
return '$senderName покинул(а) группу';
case 'title':
final newTitle = controlAttach['title'] ?? '';
if (newTitle.isEmpty) {
return '$senderDisplayName изменил(а) название группы';
}
return '$senderDisplayName изменил(а) название группы на "$newTitle"';
case 'avatar':
case 'photo':
return '$senderDisplayName изменил(а) фото группы';
case 'description':
return '$senderDisplayName изменил(а) описание группы';
case 'admin':
case 'promote':
final userIds = List<int>.from(
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
);
if (userIds.isEmpty) {
return '$senderDisplayName назначил(а) администраторов';
}
final userNames = _formatUserList(userIds);
if (userNames.isEmpty) {
return '$senderDisplayName назначил(а) администраторов';
}
if (userIds.contains(myId) && userIds.length == 1) {
return 'Вас назначили администратором';
}
return '$senderDisplayName назначил(а) администраторами: $userNames';
case 'demote':
case 'remove_admin':
final userIds = List<int>.from(
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
);
if (userIds.isEmpty) {
return '$senderDisplayName снял(а) администраторов';
}
final userNames = _formatUserList(userIds);
if (userNames.isEmpty) {
return '$senderDisplayName снял(а) администраторов';
}
if (userIds.contains(myId) && userIds.length == 1) {
return 'Вас сняли с должности администратора';
}
return '$senderDisplayName снял(а) с должности администратора: $userNames';
case 'ban':
final userIds = List<int>.from(
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
);
if (userIds.isEmpty) {
return '$senderDisplayName заблокировал(а) участников';
}
final userNames = _formatUserList(userIds);
if (userNames.isEmpty) {
return '$senderDisplayName заблокировал(а) участников';
}
if (userIds.contains(myId)) {
return 'Вы были заблокированы в чате';
}
return '$senderDisplayName заблокировал(а): $userNames';
case 'unban':
final userIds = List<int>.from(
(controlAttach['userIds'] as List?)?.map((id) => id as int) ?? [],
);
if (userIds.isEmpty) {
return '$senderDisplayName разблокировал(а) участников';
}
final userNames = _formatUserList(userIds);
if (userNames.isEmpty) {
return '$senderDisplayName разблокировал(а) участников';
}
return '$senderDisplayName разблокировал(а): $userNames';
case 'join':
if (isMe) {
return 'Вы присоединились к группе';
}
return '$senderName присоединился(ась) к группе';
default:
final eventTypeStr = eventType?.toString() ?? 'неизвестное';
return 'Событие: $eventTypeStr';
}
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.5),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_formatControlMessage(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
),
),
);
}
}
void openUserProfileById(BuildContext context, int userId) {
final contact = ApiService.instance.getCachedContact(userId);
if (contact != null) {
final isGroup = contact.id < 0; // Groups have negative IDs
if (isGroup) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => GroupProfileDraggableDialog(contact: contact),
);
} else {
Navigator.of(context).push(
PageRouteBuilder(
opaque: false,
barrierColor: Colors.transparent,
pageBuilder: (context, animation, secondaryAnimation) {
return ContactProfileDialog(contact: contact);
},
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 300),
),
);
}
} else {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Профиль пользователя $userId'),
content: Text('Информация о пользователе не найдена в кэше'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK'),
),
],
),
);
}
}
class _VideoWallpaperBackground extends StatefulWidget {
final String videoPath;
const _VideoWallpaperBackground({required this.videoPath});
@override
State<_VideoWallpaperBackground> createState() =>
_VideoWallpaperBackgroundState();
}
class _VideoWallpaperBackgroundState extends State<_VideoWallpaperBackground> {
VideoPlayerController? _controller;
String? _errorMessage;
@override
void initState() {
super.initState();
_initializeVideo();
}
Future<void> _initializeVideo() async {
try {
final file = File(widget.videoPath);
if (!await file.exists()) {
setState(() {
_errorMessage = 'Video file not found';
});
print('ERROR: Video file does not exist: ${widget.videoPath}');
return;
}
_controller = VideoPlayerController.file(file);
await _controller!.initialize();
if (mounted) {
_controller!.setVolume(0);
_controller!.setLooping(true);
_controller!.play();
setState(() {});
print('SUCCESS: Video initialized and playing');
}
} catch (e) {
print('ERROR initializing video: $e');
setState(() {
_errorMessage = e.toString();
});
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_errorMessage != null) {
print('ERROR building video widget: $_errorMessage');
return Container(
color: Colors.black,
child: Center(
child: Text(
'Error loading video\n$_errorMessage',
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
);
}
if (_controller == null) {
return const Center(child: CircularProgressIndicator());
}
if (!_controller!.value.isInitialized) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _controller!.value.size.width,
height: _controller!.value.size.height,
child: VideoPlayer(_controller!),
),
),
),
Container(color: Colors.black.withOpacity(0.3)),
],
);
}
}