4940 lines
167 KiB
Dart
4940 lines
167 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/scheduler.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'package:gwid/api/api_service.dart';
|
||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||
import 'package:gwid/chat_screen.dart';
|
||
import 'package:gwid/manage_account_screen.dart';
|
||
import 'package:gwid/screens/settings/settings_screen.dart';
|
||
import 'package:gwid/phone_entry_screen.dart';
|
||
import 'package:gwid/models/chat.dart';
|
||
import 'package:gwid/models/contact.dart';
|
||
import 'package:gwid/models/message.dart';
|
||
import 'package:gwid/models/profile.dart';
|
||
import 'package:gwid/models/chat_folder.dart';
|
||
import 'package:gwid/theme_provider.dart';
|
||
import 'package:provider/provider.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'package:gwid/join_group_screen.dart';
|
||
import 'package:gwid/search_contact_screen.dart';
|
||
import 'package:gwid/channels_list_screen.dart';
|
||
import 'package:gwid/models/channel.dart';
|
||
import 'package:gwid/search_channels_screen.dart';
|
||
import 'package:gwid/downloads_screen.dart';
|
||
import 'package:gwid/user_id_lookup_screen.dart';
|
||
import 'package:gwid/screens/music_library_screen.dart';
|
||
import 'package:gwid/widgets/message_preview_dialog.dart';
|
||
import 'package:gwid/services/chat_read_settings_service.dart';
|
||
import 'package:gwid/services/local_profile_manager.dart';
|
||
import 'package:gwid/widgets/contact_name_widget.dart';
|
||
import 'package:gwid/widgets/contact_avatar_widget.dart';
|
||
import 'package:gwid/services/contact_local_names_service.dart';
|
||
import 'package:gwid/services/account_manager.dart';
|
||
import 'package:gwid/models/account.dart';
|
||
|
||
class SearchResult {
|
||
final Chat chat;
|
||
final Contact? contact;
|
||
final String matchedText;
|
||
final String matchType;
|
||
final int? messageIndex;
|
||
|
||
SearchResult({
|
||
required this.chat,
|
||
this.contact,
|
||
required this.matchedText,
|
||
required this.matchType,
|
||
this.messageIndex,
|
||
});
|
||
}
|
||
|
||
class ChatsScreen extends StatefulWidget {
|
||
final void Function(
|
||
Chat chat,
|
||
Contact contact,
|
||
bool isGroup,
|
||
bool isChannel,
|
||
int? participantCount,
|
||
)?
|
||
onChatSelected;
|
||
final bool hasScaffold;
|
||
|
||
const ChatsScreen({super.key, this.onChatSelected, this.hasScaffold = true});
|
||
|
||
@override
|
||
State<ChatsScreen> createState() => _ChatsScreenState();
|
||
}
|
||
|
||
class _ChatsScreenState extends State<ChatsScreen>
|
||
with AutomaticKeepAliveClientMixin, TickerProviderStateMixin {
|
||
late Future<Map<String, dynamic>> _chatsFuture;
|
||
bool _showChannelsRail = false;
|
||
List<Channel> _channels = [];
|
||
bool _channelsLoaded = false;
|
||
StreamSubscription? _apiSubscription;
|
||
List<Chat> _allChats = [];
|
||
List<Chat> _filteredChats = [];
|
||
Map<int, Contact> _contacts = {};
|
||
bool _isSearchExpanded = false;
|
||
String _searchQuery = '';
|
||
Timer? _searchDebounceTimer;
|
||
List<SearchResult> _searchResults = [];
|
||
String _searchFilter = 'all';
|
||
bool _hasRequestedBlockedContacts = false;
|
||
final Set<int> _loadingContactIds = {};
|
||
|
||
List<ChatFolder> _folders = [];
|
||
String? _selectedFolderId;
|
||
late TabController _folderTabController;
|
||
|
||
final TextEditingController _searchController = TextEditingController();
|
||
final FocusNode _searchFocusNode = FocusNode();
|
||
late AnimationController _searchAnimationController;
|
||
Profile? _myProfile;
|
||
bool _isProfileLoading = true;
|
||
String _connectionStatus = 'connecting';
|
||
StreamSubscription<void>? _connectionStatusSubscription;
|
||
StreamSubscription<String>? _connectionStateSubscription;
|
||
bool _isAccountsExpanded = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadMyProfile();
|
||
_chatsFuture = (() async {
|
||
try {
|
||
await ApiService.instance.waitUntilOnline();
|
||
return ApiService.instance.getChatsAndContacts();
|
||
} catch (e) {
|
||
print('Ошибка получения чатов: $e');
|
||
if (e.toString().contains('Auth token not found') ||
|
||
e.toString().contains('FAIL_WRONG_PASSWORD')) {
|
||
_showTokenExpiredDialog(
|
||
'Токен авторизации недействителен. Требуется повторная авторизация.',
|
||
);
|
||
}
|
||
rethrow;
|
||
}
|
||
})();
|
||
_listenForUpdates();
|
||
|
||
_searchAnimationController = AnimationController(
|
||
duration: const Duration(milliseconds: 300),
|
||
vsync: this,
|
||
);
|
||
|
||
_folderTabController = TabController(length: 1, vsync: this);
|
||
_folderTabController.addListener(_onFolderTabChanged);
|
||
|
||
_searchController.addListener(_onSearchChanged);
|
||
_searchFocusNode.addListener(_onSearchFocusChanged);
|
||
|
||
_connectionStateSubscription = ApiService.instance.connectionStatus.listen((
|
||
status,
|
||
) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_connectionStatus = status;
|
||
});
|
||
}
|
||
});
|
||
|
||
_connectionStatusSubscription = ApiService.instance.reconnectionComplete
|
||
.listen((_) {
|
||
if (mounted) {
|
||
print("🔄 ChatsScreen: Получено уведомление о переподключении");
|
||
_loadChatsAndContacts();
|
||
print("🔄 ChatsScreen: Обновление чатов запущено");
|
||
}
|
||
});
|
||
|
||
Future.delayed(const Duration(seconds: 3), () {
|
||
if (mounted) {
|
||
_loadChannels();
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
bool get wantKeepAlive => true;
|
||
|
||
Future<void> _loadMyProfile() async {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_isProfileLoading = true;
|
||
});
|
||
|
||
Profile? serverProfile;
|
||
|
||
try {
|
||
final accountManager = AccountManager();
|
||
await accountManager.initialize();
|
||
final currentAccount = accountManager.currentAccount;
|
||
if (currentAccount?.profile != null) {
|
||
serverProfile = currentAccount!.profile;
|
||
}
|
||
} catch (e) {
|
||
print('Ошибка загрузки профиля из AccountManager: $e');
|
||
}
|
||
|
||
if (serverProfile == null) {
|
||
final cachedProfileData =
|
||
ApiService.instance.lastChatsPayload?['profile'];
|
||
if (cachedProfileData != null) {
|
||
serverProfile = Profile.fromJson(cachedProfileData);
|
||
}
|
||
}
|
||
|
||
try {
|
||
final profileManager = LocalProfileManager();
|
||
await profileManager.initialize();
|
||
final actualProfile = await profileManager.getActualProfile(
|
||
serverProfile,
|
||
);
|
||
|
||
if (mounted && actualProfile != null) {
|
||
setState(() {
|
||
_myProfile = actualProfile;
|
||
_isProfileLoading = false;
|
||
});
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
print('Ошибка загрузки локального профиля: $e');
|
||
}
|
||
|
||
if (mounted && serverProfile != null) {
|
||
setState(() {
|
||
_myProfile = serverProfile;
|
||
_isProfileLoading = false;
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (!ApiService.instance.isOnline) {
|
||
await ApiService.instance.waitUntilOnline().timeout(
|
||
const Duration(seconds: 10),
|
||
onTimeout: () {
|
||
print("Таймаут ожидания подключения для загрузки профиля");
|
||
throw TimeoutException("Таймаут подключения");
|
||
},
|
||
);
|
||
}
|
||
|
||
final result = await ApiService.instance
|
||
.getChatsAndContacts(force: true)
|
||
.timeout(
|
||
const Duration(seconds: 15),
|
||
onTimeout: () {
|
||
print("Таймаут загрузки чатов и профиля");
|
||
throw TimeoutException("Таймаут загрузки");
|
||
},
|
||
);
|
||
|
||
if (mounted) {
|
||
final profileJson = result['profile'];
|
||
if (profileJson != null) {
|
||
setState(() {
|
||
_myProfile = Profile.fromJson(profileJson);
|
||
_isProfileLoading = false;
|
||
});
|
||
} else {
|
||
setState(() {
|
||
_isProfileLoading = false;
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isProfileLoading = false;
|
||
});
|
||
print("Ошибка загрузки профиля в ChatsScreen: $e");
|
||
}
|
||
}
|
||
}
|
||
|
||
void _navigateToLogin() {
|
||
print('Перенаправляем на экран входа из-за недействительного токена');
|
||
Navigator.of(context).pushReplacement(
|
||
MaterialPageRoute(builder: (context) => const PhoneEntryScreen()),
|
||
);
|
||
}
|
||
|
||
void _showTokenExpiredDialog(String message) {
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (BuildContext context) {
|
||
return AlertDialog(
|
||
title: const Text('Ошибка авторизации'),
|
||
content: Text(message),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
_navigateToLogin();
|
||
},
|
||
child: const Text('Войти заново'),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _listenForUpdates() {
|
||
_apiSubscription = ApiService.instance.messages.listen((message) {
|
||
if (!mounted) return;
|
||
|
||
if (message['type'] == 'invalid_token') {
|
||
print(
|
||
'Получено событие недействительного токена, перенаправляем на вход',
|
||
);
|
||
_showTokenExpiredDialog(
|
||
message['message'] ?? 'Токен авторизации недействителен',
|
||
);
|
||
return;
|
||
}
|
||
|
||
final opcode = message['opcode'];
|
||
final cmd = message['cmd'];
|
||
final payload = message['payload'];
|
||
|
||
if (opcode == 19 && cmd == 1 && payload != null) {
|
||
final profileData = payload['profile'];
|
||
if (profileData != null) {
|
||
print('🔄 ChatsScreen: Получен профиль из opcode 19, обновляем UI');
|
||
if (mounted) {
|
||
setState(() {
|
||
_myProfile = Profile.fromJson(profileData);
|
||
_isProfileLoading = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (payload == null) return;
|
||
final chatIdValue = payload['chatId'];
|
||
final int? chatId = chatIdValue != null ? chatIdValue as int? : null;
|
||
|
||
if (opcode == 272 || opcode == 274) {
|
||
} else if (chatId == null) {
|
||
return;
|
||
}
|
||
|
||
if (opcode == 129 && chatId != null) {
|
||
_setTypingForChat(chatId);
|
||
}
|
||
|
||
if (opcode == 128 && chatId != null) {
|
||
final newMessage = Message.fromJson(payload['message']);
|
||
ApiService.instance.clearCacheForChat(chatId);
|
||
|
||
final int chatIndex = _allChats.indexWhere((chat) => chat.id == chatId);
|
||
if (chatIndex != -1) {
|
||
final oldChat = _allChats[chatIndex];
|
||
final updatedChat = oldChat.copyWith(
|
||
lastMessage: newMessage,
|
||
newMessages: newMessage.senderId != oldChat.ownerId
|
||
? oldChat.newMessages + 1
|
||
: oldChat.newMessages,
|
||
);
|
||
|
||
setState(() {
|
||
_allChats.removeAt(chatIndex);
|
||
|
||
if (_isSavedMessages(updatedChat)) {
|
||
if (updatedChat.id == 0) {
|
||
_allChats.insert(0, updatedChat);
|
||
} else {
|
||
final savedIndex = _allChats.indexWhere(
|
||
(c) => _isSavedMessages(c) && c.id == 0,
|
||
);
|
||
final insertIndex = savedIndex >= 0 ? savedIndex + 1 : 0;
|
||
_allChats.insert(insertIndex, updatedChat);
|
||
}
|
||
} else {
|
||
final savedIndex = _allChats.indexWhere(
|
||
(c) => _isSavedMessages(c),
|
||
);
|
||
final insertIndex = savedIndex >= 0 ? savedIndex + 1 : 0;
|
||
_allChats.insert(insertIndex, updatedChat);
|
||
}
|
||
_filterChats();
|
||
});
|
||
}
|
||
} else if (opcode == 67 && chatId != null) {
|
||
final editedMessage = Message.fromJson(payload['message']);
|
||
ApiService.instance.clearCacheForChat(chatId);
|
||
|
||
final int chatIndex = _allChats.indexWhere((chat) => chat.id == chatId);
|
||
if (chatIndex != -1) {
|
||
final oldChat = _allChats[chatIndex];
|
||
|
||
if (oldChat.lastMessage.id == editedMessage.id) {
|
||
final updatedChat = oldChat.copyWith(lastMessage: editedMessage);
|
||
setState(() {
|
||
_allChats.removeAt(chatIndex);
|
||
|
||
if (_isSavedMessages(updatedChat)) {
|
||
if (updatedChat.id == 0) {
|
||
_allChats.insert(0, updatedChat);
|
||
} else {
|
||
final savedIndex = _allChats.indexWhere(
|
||
(c) => _isSavedMessages(c) && c.id == 0,
|
||
);
|
||
final insertIndex = savedIndex >= 0 ? savedIndex + 1 : 0;
|
||
_allChats.insert(insertIndex, updatedChat);
|
||
}
|
||
} else {
|
||
final savedIndex = _allChats.indexWhere(
|
||
(c) => _isSavedMessages(c),
|
||
);
|
||
final insertIndex = savedIndex >= 0 ? savedIndex + 1 : 0;
|
||
_allChats.insert(insertIndex, updatedChat);
|
||
}
|
||
_filterChats();
|
||
});
|
||
}
|
||
}
|
||
} else if (opcode == 66 && chatId != null) {
|
||
final deletedMessageIds = List<String>.from(
|
||
payload['messageIds'] ?? [],
|
||
);
|
||
ApiService.instance.clearCacheForChat(chatId);
|
||
|
||
final int chatIndex = _allChats.indexWhere((chat) => chat.id == chatId);
|
||
if (chatIndex != -1) {
|
||
final oldChat = _allChats[chatIndex];
|
||
|
||
if (deletedMessageIds.contains(oldChat.lastMessage.id)) {
|
||
ApiService.instance.getChatsOnly(force: true).then((data) {
|
||
if (mounted) {
|
||
final chats = data['chats'] as List<dynamic>;
|
||
final filtered = chats
|
||
.cast<Map<String, dynamic>>()
|
||
.where((chat) => chat['id'] == chatId)
|
||
.toList();
|
||
final Map<String, dynamic>? updatedChatData =
|
||
filtered.isNotEmpty ? filtered.first : null;
|
||
if (updatedChatData != null) {
|
||
final updatedChat = Chat.fromJson(updatedChatData);
|
||
setState(() {
|
||
_allChats.removeAt(chatIndex);
|
||
_allChats.insert(0, updatedChat);
|
||
_filterChats();
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (opcode == 129 && chatId != null) {
|
||
_setTypingForChat(chatId);
|
||
}
|
||
|
||
if (opcode == 132) {
|
||
final bool isOnline = payload['online'] == true;
|
||
|
||
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 userPresence = {
|
||
'seen': currentTime,
|
||
'on': isOnline ? 'ON' : 'OFF',
|
||
};
|
||
ApiService.instance.updatePresenceData({
|
||
cid.toString(): userPresence,
|
||
});
|
||
|
||
print(
|
||
'Обновлен presence для пользователя $cid: online=$isOnline, seen=$currentTime',
|
||
);
|
||
|
||
for (final chat in _allChats) {
|
||
final otherId = chat.participantIds.firstWhere(
|
||
(id) => id != chat.ownerId,
|
||
orElse: () => chat.ownerId,
|
||
);
|
||
if (otherId == cid) {
|
||
if (isOnline) {
|
||
_onlineChats.add(chat.id);
|
||
} else {
|
||
_onlineChats.remove(chat.id);
|
||
}
|
||
}
|
||
}
|
||
if (mounted) setState(() {});
|
||
return;
|
||
}
|
||
}
|
||
|
||
final dynamic cidAny = payload['chatId'];
|
||
final int? chatIdFromPayload = cidAny is int
|
||
? cidAny
|
||
: int.tryParse(cidAny?.toString() ?? '');
|
||
if (chatIdFromPayload != null) {
|
||
if (isOnline) {
|
||
_onlineChats.add(chatIdFromPayload);
|
||
} else {
|
||
_onlineChats.remove(chatIdFromPayload);
|
||
}
|
||
if (mounted) setState(() {});
|
||
}
|
||
}
|
||
|
||
if (opcode == 36 && payload['contacts'] != null) {
|
||
final List<dynamic> blockedContactsJson = payload['contacts'] as List;
|
||
final blockedContacts = blockedContactsJson
|
||
.map((json) => Contact.fromJson(json))
|
||
.toList();
|
||
|
||
for (final blockedContact in blockedContacts) {
|
||
print(
|
||
'Обновляем контакт ${blockedContact.name} (ID: ${blockedContact.id}): isBlocked=${blockedContact.isBlocked}, isBlockedByMe=${blockedContact.isBlockedByMe}',
|
||
);
|
||
if (_contacts.containsKey(blockedContact.id)) {
|
||
_contacts[blockedContact.id] = blockedContact;
|
||
print(
|
||
'Обновлен существующий контакт: ${_contacts[blockedContact.id]?.name}',
|
||
);
|
||
|
||
ApiService.instance.notifyContactUpdate(blockedContact);
|
||
} else {
|
||
_contacts[blockedContact.id] = blockedContact;
|
||
print(
|
||
'Добавлен новый заблокированный контакт: ${blockedContact.name}',
|
||
);
|
||
|
||
ApiService.instance.notifyContactUpdate(blockedContact);
|
||
}
|
||
}
|
||
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
if (opcode == 48) {
|
||
print('Получен ответ на создание группы: $payload');
|
||
|
||
_refreshChats();
|
||
}
|
||
|
||
if (opcode == 272) {
|
||
print('Получен ответ на обновление папок: $payload');
|
||
|
||
if (payload['folders'] != null || payload['foldersOrder'] != null) {
|
||
try {
|
||
final foldersJson = payload['folders'] as List<dynamic>?;
|
||
if (foldersJson != null) {
|
||
final folders = foldersJson
|
||
.map(
|
||
(json) => ChatFolder.fromJson(json as Map<String, dynamic>),
|
||
)
|
||
.toList();
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
_folders = folders;
|
||
final foldersOrder =
|
||
payload['foldersOrder'] as List<dynamic>?;
|
||
_sortFoldersByOrder(foldersOrder);
|
||
});
|
||
_updateFolderTabController();
|
||
_filterChats();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
print('Ошибка обработки папок из opcode 272: $e');
|
||
}
|
||
} else {
|
||
_refreshChats();
|
||
}
|
||
}
|
||
|
||
if (opcode == 274 && cmd == 1) {
|
||
print('Получен ответ на создание/обновление папки: $payload');
|
||
|
||
try {
|
||
final folderJson = payload['folder'] as Map<String, dynamic>?;
|
||
if (folderJson != null) {
|
||
final updatedFolder = ChatFolder.fromJson(folderJson);
|
||
final folderId = updatedFolder.id;
|
||
|
||
if (mounted) {
|
||
final existingIndex = _folders.indexWhere(
|
||
(f) => f.id == folderId,
|
||
);
|
||
final isNewFolder = existingIndex == -1;
|
||
|
||
setState(() {
|
||
if (existingIndex != -1) {
|
||
_folders[existingIndex] = updatedFolder;
|
||
} else {
|
||
_folders.add(updatedFolder);
|
||
}
|
||
|
||
final foldersOrder = payload['foldersOrder'] as List<dynamic>?;
|
||
_sortFoldersByOrder(foldersOrder);
|
||
});
|
||
|
||
_updateFolderTabController();
|
||
_filterChats();
|
||
|
||
if (isNewFolder) {
|
||
final newFolderIndex = _folders.indexWhere(
|
||
(f) => f.id == folderId,
|
||
);
|
||
if (newFolderIndex != -1) {
|
||
final targetIndex = newFolderIndex + 1;
|
||
if (_folderTabController.length > targetIndex) {
|
||
_folderTabController.animateTo(targetIndex);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
print(
|
||
'Ошибка обработки созданной/обновленной папки из opcode 274: $e',
|
||
);
|
||
}
|
||
}
|
||
|
||
if (opcode == 276 && cmd == 1) {
|
||
print('Получен ответ на удаление папки: $payload');
|
||
|
||
try {
|
||
final foldersOrder = payload['foldersOrder'] as List<dynamic>?;
|
||
if (foldersOrder != null && mounted) {
|
||
final currentIndex = _folderTabController.index;
|
||
|
||
setState(() {
|
||
final orderedIds = foldersOrder
|
||
.map((id) => id.toString())
|
||
.toList();
|
||
_folders.removeWhere((folder) => !orderedIds.contains(folder.id));
|
||
_sortFoldersByOrder(foldersOrder);
|
||
});
|
||
|
||
_updateFolderTabController();
|
||
_filterChats();
|
||
|
||
if (currentIndex >= _folderTabController.length) {
|
||
_folderTabController.animateTo(0);
|
||
} else if (currentIndex > 0) {
|
||
_folderTabController.animateTo(
|
||
currentIndex < _folderTabController.length ? currentIndex : 0,
|
||
);
|
||
}
|
||
|
||
ApiService.instance.requestFolderSync();
|
||
}
|
||
} catch (e) {
|
||
print('Ошибка обработки удаления папки из opcode 276: $e');
|
||
}
|
||
}
|
||
|
||
if (message['type'] == 'channels_found') {
|
||
final payload = message['payload'];
|
||
final channelsData = payload['contacts'] as List<dynamic>?;
|
||
|
||
if (channelsData != null) {
|
||
setState(() {
|
||
_channels = channelsData
|
||
.map((channelJson) => Channel.fromJson(channelJson))
|
||
.toList();
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
final Map<int, Timer> _typingDecayTimers = {};
|
||
final Set<int> _typingChats = {};
|
||
final Set<int> _onlineChats = {};
|
||
void _setTypingForChat(int chatId) {
|
||
_typingChats.add(chatId);
|
||
_typingDecayTimers[chatId]?.cancel();
|
||
_typingDecayTimers[chatId] = Timer(const Duration(seconds: 11), () {
|
||
_typingChats.remove(chatId);
|
||
if (mounted) setState(() {});
|
||
});
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
void _refreshChats() {
|
||
_chatsFuture = ApiService.instance.getChatsAndContacts(force: true);
|
||
_chatsFuture.then((data) {
|
||
if (mounted) {
|
||
final chats = data['chats'] as List<dynamic>;
|
||
final contacts = data['contacts'] as List<dynamic>;
|
||
final profileData = data['profile'];
|
||
|
||
_allChats = chats
|
||
.where((json) => json != null)
|
||
.map((json) => Chat.fromJson(json))
|
||
.toList();
|
||
_contacts.clear();
|
||
for (final contactJson in contacts) {
|
||
final contact = Contact.fromJson(contactJson);
|
||
_contacts[contact.id] = contact;
|
||
}
|
||
|
||
setState(() {
|
||
if (profileData != null) {
|
||
_myProfile = Profile.fromJson(profileData);
|
||
_isProfileLoading = false;
|
||
}
|
||
});
|
||
|
||
_filterChats();
|
||
}
|
||
});
|
||
}
|
||
|
||
Widget _buildChannelsRail() {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
return Container(
|
||
width: 280,
|
||
decoration: BoxDecoration(
|
||
color: colors.surface,
|
||
border: Border(
|
||
right: BorderSide(color: colors.outline.withOpacity(0.2), width: 1),
|
||
),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: colors.surfaceContainerHighest,
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: colors.outline.withOpacity(0.2),
|
||
width: 1,
|
||
),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.broadcast_on_personal,
|
||
color: colors.primary,
|
||
size: 20,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
'Каналы',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
color: colors.onSurface,
|
||
fontSize: 16,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
IconButton(
|
||
icon: const Icon(Icons.search, size: 20),
|
||
onPressed: () {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => const ChannelsListScreen(),
|
||
),
|
||
);
|
||
},
|
||
tooltip: 'Поиск каналов',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
Expanded(child: _buildChannelsList()),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _loadChannels() async {
|
||
if (_channelsLoaded) return;
|
||
|
||
try {
|
||
await ApiService.instance.searchChannels('каналы');
|
||
_channelsLoaded = true;
|
||
} catch (e) {
|
||
print('Ошибка загрузки каналов: $e');
|
||
}
|
||
}
|
||
|
||
Widget _buildChannelsList() {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
if (_channels.isEmpty) {
|
||
return ListView(
|
||
padding: const EdgeInsets.all(8),
|
||
children: [
|
||
_buildChannelItem(
|
||
'Новости',
|
||
'Актуальные новости',
|
||
Icons.newspaper,
|
||
colors.primaryContainer,
|
||
colors.onPrimaryContainer,
|
||
),
|
||
_buildChannelItem(
|
||
'Технологии',
|
||
'IT и технологии',
|
||
Icons.computer,
|
||
colors.secondaryContainer,
|
||
colors.onSecondaryContainer,
|
||
),
|
||
_buildChannelItem(
|
||
'Спорт',
|
||
'Спортивные новости',
|
||
Icons.sports,
|
||
colors.tertiaryContainer,
|
||
colors.onTertiaryContainer,
|
||
),
|
||
_buildChannelItem(
|
||
'Развлечения',
|
||
'Фильмы, музыка, игры',
|
||
Icons.movie,
|
||
colors.errorContainer,
|
||
colors.onErrorContainer,
|
||
),
|
||
_buildChannelItem(
|
||
'Образование',
|
||
'Учеба и развитие',
|
||
Icons.school,
|
||
colors.primaryContainer,
|
||
colors.onPrimaryContainer,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.all(8),
|
||
itemCount: _channels.length,
|
||
itemBuilder: (context, index) {
|
||
final channel = _channels[index];
|
||
return _buildRealChannelItem(channel);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildChannelItem(
|
||
String title,
|
||
String subtitle,
|
||
IconData icon,
|
||
Color backgroundColor,
|
||
Color iconColor,
|
||
) {
|
||
return Card(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
child: ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
leading: CircleAvatar(
|
||
radius: 16,
|
||
backgroundColor: backgroundColor,
|
||
child: Icon(icon, size: 16, color: iconColor),
|
||
),
|
||
title: Text(
|
||
title,
|
||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||
),
|
||
subtitle: Text(
|
||
subtitle,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
onTap: () {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Открытие канала: $title'),
|
||
backgroundColor: Colors.blue,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
behavior: SnackBarBehavior.floating,
|
||
margin: const EdgeInsets.all(10),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildRealChannelItem(Channel channel) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
return Card(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
child: ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
leading: CircleAvatar(
|
||
radius: 16,
|
||
backgroundImage: channel.photoBaseUrl != null
|
||
? NetworkImage(channel.photoBaseUrl!)
|
||
: null,
|
||
child: channel.photoBaseUrl == null
|
||
? Text(
|
||
channel.name.isNotEmpty ? channel.name[0].toUpperCase() : '?',
|
||
style: TextStyle(
|
||
color: colors.onSurface,
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 12,
|
||
),
|
||
)
|
||
: null,
|
||
),
|
||
title: Text(
|
||
channel.name,
|
||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||
),
|
||
subtitle: Text(
|
||
channel.description ?? 'Канал',
|
||
style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
onTap: () {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => ChannelDetailsScreen(channel: channel),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showAddMenu(BuildContext context) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
Text(
|
||
'Создать',
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
ListTile(
|
||
leading: CircleAvatar(
|
||
backgroundColor: Theme.of(
|
||
context,
|
||
).colorScheme.primaryContainer,
|
||
child: Icon(
|
||
Icons.group_add,
|
||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||
),
|
||
),
|
||
title: const Text('Создать группу'),
|
||
subtitle: const Text('Создать чат с несколькими участниками'),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
_showCreateGroupDialog();
|
||
},
|
||
),
|
||
|
||
ListTile(
|
||
leading: CircleAvatar(
|
||
backgroundColor: Theme.of(
|
||
context,
|
||
).colorScheme.primaryContainer,
|
||
child: Icon(
|
||
Icons.person_search,
|
||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||
),
|
||
),
|
||
title: const Text('Найти контакт'),
|
||
subtitle: const Text('Поиск по номеру телефона'),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => const SearchContactScreen(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
|
||
ListTile(
|
||
leading: CircleAvatar(
|
||
backgroundColor: Theme.of(
|
||
context,
|
||
).colorScheme.tertiaryContainer,
|
||
child: Icon(
|
||
Icons.broadcast_on_personal,
|
||
color: Theme.of(context).colorScheme.onTertiaryContainer,
|
||
),
|
||
),
|
||
title: const Text('Каналы'),
|
||
subtitle: const Text('Просмотр и подписка на каналы'),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => const ChannelsListScreen(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
|
||
ListTile(
|
||
leading: CircleAvatar(
|
||
backgroundColor: Theme.of(
|
||
context,
|
||
).colorScheme.secondaryContainer,
|
||
child: Icon(
|
||
Icons.link,
|
||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||
),
|
||
),
|
||
title: const Text('Присоединиться к группе'),
|
||
subtitle: const Text('По ссылке-приглашению'),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => const JoinGroupScreen(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
|
||
ListTile(
|
||
leading: CircleAvatar(
|
||
backgroundColor: Theme.of(
|
||
context,
|
||
).colorScheme.primaryContainer,
|
||
child: Icon(
|
||
Icons.download,
|
||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||
),
|
||
),
|
||
title: const Text('Загрузки'),
|
||
subtitle: const Text('Скачанные файлы'),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => const DownloadsScreen(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
|
||
const SizedBox(height: 20),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _showCreateGroupDialog() {
|
||
final TextEditingController nameController = TextEditingController();
|
||
final List<int> selectedContacts = [];
|
||
|
||
final int? myId = _myProfile?.id;
|
||
|
||
final List<Contact> availableContacts = _contacts.values.where((contact) {
|
||
final contactNameLower = contact.name.toLowerCase();
|
||
return contactNameLower != 'max' &&
|
||
contactNameLower != 'gigachat' &&
|
||
(myId == null || contact.id != myId);
|
||
}).toList();
|
||
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => StatefulBuilder(
|
||
builder: (context, setState) => AlertDialog(
|
||
title: const Text('Создать группу'),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TextField(
|
||
controller: nameController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Название группы',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
const Text('Выберите участников:'),
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
height: 200,
|
||
width: 300,
|
||
|
||
child: ListView.builder(
|
||
itemCount: availableContacts.length,
|
||
itemBuilder: (context, index) {
|
||
final contact = availableContacts[index];
|
||
final isSelected = selectedContacts.contains(contact.id);
|
||
|
||
return CheckboxListTile(
|
||
title: Text(
|
||
getContactDisplayName(
|
||
contactId: contact.id,
|
||
originalName: contact.name,
|
||
originalFirstName: contact.firstName,
|
||
originalLastName: contact.lastName,
|
||
),
|
||
),
|
||
subtitle: Text(
|
||
contact.firstName.isNotEmpty &&
|
||
contact.lastName.isNotEmpty
|
||
? '${contact.firstName} ${contact.lastName}'
|
||
: '',
|
||
),
|
||
value: isSelected,
|
||
onChanged: (value) {
|
||
setState(() {
|
||
if (value == true) {
|
||
selectedContacts.add(contact.id);
|
||
} else {
|
||
selectedContacts.remove(contact.id);
|
||
}
|
||
});
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () {
|
||
if (nameController.text.trim().isNotEmpty) {
|
||
ApiService.instance.createGroupWithMessage(
|
||
nameController.text.trim(),
|
||
selectedContacts, // Будет [] если никого не выбрали
|
||
);
|
||
Navigator.of(context).pop();
|
||
}
|
||
},
|
||
child: const Text('Создать'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
bool _isSavedMessages(Chat chat) {
|
||
return chat.id == 0;
|
||
}
|
||
|
||
bool _isGroupChat(Chat chat) {
|
||
return chat.type == 'CHAT' || chat.participantIds.length > 2;
|
||
}
|
||
|
||
void _updateFolderTabController() {
|
||
final oldIndex = _folderTabController.index;
|
||
final newLength = 1 + _folders.length;
|
||
if (_folderTabController.length != newLength) {
|
||
_folderTabController.removeListener(_onFolderTabChanged);
|
||
_folderTabController.dispose();
|
||
_folderTabController = TabController(
|
||
length: newLength,
|
||
vsync: this,
|
||
initialIndex: oldIndex < newLength ? oldIndex : 0,
|
||
);
|
||
_folderTabController.addListener(_onFolderTabChanged);
|
||
}
|
||
}
|
||
|
||
void _sortFoldersByOrder(List<dynamic>? foldersOrder) {
|
||
if (foldersOrder == null || foldersOrder.isEmpty) return;
|
||
|
||
final orderedIds = foldersOrder.map((id) => id.toString()).toList();
|
||
_folders.sort((a, b) {
|
||
final aIndex = orderedIds.indexOf(a.id);
|
||
final bIndex = orderedIds.indexOf(b.id);
|
||
if (aIndex == -1 && bIndex == -1) return 0;
|
||
if (aIndex == -1) return 1;
|
||
if (bIndex == -1) return -1;
|
||
return aIndex.compareTo(bIndex);
|
||
});
|
||
}
|
||
|
||
void _loadFolders(Map<String, dynamic> data) {
|
||
try {
|
||
final config = data['config'] as Map<String, dynamic>?;
|
||
if (config == null) return;
|
||
|
||
final chatFolders = config['chatFolders'] as Map<String, dynamic>?;
|
||
if (chatFolders == null) return;
|
||
|
||
final foldersJson = chatFolders['FOLDERS'] as List<dynamic>?;
|
||
if (foldersJson == null) return;
|
||
|
||
final folders = foldersJson
|
||
.map((json) => ChatFolder.fromJson(json as Map<String, dynamic>))
|
||
.toList();
|
||
|
||
setState(() {
|
||
_folders = folders;
|
||
|
||
final foldersOrder = chatFolders['foldersOrder'] as List<dynamic>?;
|
||
_sortFoldersByOrder(foldersOrder);
|
||
|
||
_updateFolderTabController();
|
||
|
||
if (_selectedFolderId == null) {
|
||
if (_folderTabController.index != 0) {
|
||
_folderTabController.animateTo(0);
|
||
}
|
||
} else {
|
||
final folderIndex = _folders.indexWhere(
|
||
(f) => f.id == _selectedFolderId,
|
||
);
|
||
if (folderIndex != -1) {
|
||
final targetIndex = folderIndex + 1;
|
||
if (_folderTabController.index != targetIndex) {
|
||
_folderTabController.animateTo(targetIndex);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
} catch (e) {
|
||
print('Ошибка загрузки папок: $e');
|
||
}
|
||
}
|
||
|
||
bool _chatBelongsToFolder(Chat chat, ChatFolder? folder) {
|
||
if (folder == null) return true;
|
||
|
||
if (folder.include != null && folder.include!.isNotEmpty) {
|
||
return folder.include!.contains(chat.id);
|
||
}
|
||
|
||
if (folder.filters.isNotEmpty) {
|
||
final hasContact = folder.filters.any(
|
||
(f) => f == 9 || f == '9' || f == 'CONTACT',
|
||
);
|
||
final hasNotContact = folder.filters.any(
|
||
(f) => f == 8 || f == '8' || f == 'NOT_CONTACT',
|
||
);
|
||
|
||
if (hasContact && hasNotContact) {
|
||
if (chat.type != 'DIALOG' ||
|
||
chat.participantIds.length > 2 ||
|
||
_isGroupChat(chat)) {
|
||
return false;
|
||
}
|
||
|
||
final otherParticipantId = chat.participantIds.firstWhere(
|
||
(id) => id != chat.ownerId,
|
||
orElse: () => 0,
|
||
);
|
||
if (otherParticipantId != 0) {
|
||
final contact = _contacts[otherParticipantId];
|
||
if (contact != null && contact.isBot) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
for (final filter in folder.filters) {
|
||
bool matchesThisFilter = false;
|
||
if (filter == 0 || filter == '0' || filter == 'UNREAD') {
|
||
matchesThisFilter = chat.newMessages > 0;
|
||
} else if (filter == 9 || filter == '9' || filter == 'CONTACT') {
|
||
if (chat.type != 'DIALOG' ||
|
||
chat.participantIds.length > 2 ||
|
||
_isGroupChat(chat)) {
|
||
matchesThisFilter = false;
|
||
} else {
|
||
final otherParticipantId = chat.participantIds.firstWhere(
|
||
(id) => id != chat.ownerId,
|
||
orElse: () => 0,
|
||
);
|
||
if (otherParticipantId != 0) {
|
||
final contact = _contacts[otherParticipantId];
|
||
matchesThisFilter = contact == null || !contact.isBot;
|
||
} else {
|
||
matchesThisFilter = true;
|
||
}
|
||
}
|
||
} else if (filter == 8 || filter == '8' || filter == 'NOT_CONTACT') {
|
||
matchesThisFilter =
|
||
chat.type == 'CHAT' ||
|
||
chat.type == 'CHANNEL' ||
|
||
_isGroupChat(chat);
|
||
} else {
|
||
matchesThisFilter = false;
|
||
}
|
||
|
||
if (matchesThisFilter) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
void _filterChats() {
|
||
final query = _searchController.text.toLowerCase();
|
||
setState(() {
|
||
List<Chat> chatsToFilter = _allChats;
|
||
|
||
if (_selectedFolderId != null) {
|
||
final selectedFolder = _folders.firstWhere(
|
||
(f) => f.id == _selectedFolderId,
|
||
orElse: () => _folders.first,
|
||
);
|
||
chatsToFilter = _allChats
|
||
.where((chat) => _chatBelongsToFolder(chat, selectedFolder))
|
||
.toList();
|
||
}
|
||
|
||
if (query.isEmpty && !_searchFocusNode.hasFocus) {
|
||
_filteredChats = List.from(chatsToFilter);
|
||
|
||
_filteredChats.sort((a, b) {
|
||
final aIsSaved = _isSavedMessages(a);
|
||
final bIsSaved = _isSavedMessages(b);
|
||
if (aIsSaved && !bIsSaved) return -1; // Избранное в начало
|
||
if (!aIsSaved && bIsSaved) return 1; // Избранное в начало
|
||
|
||
if (aIsSaved && bIsSaved) {
|
||
if (a.id == 0) return -1;
|
||
if (b.id == 0) return 1;
|
||
}
|
||
return 0; // Остальные чаты сохраняют порядок
|
||
});
|
||
} else if (_searchFocusNode.hasFocus && query.isEmpty) {
|
||
_filteredChats = [];
|
||
} else if (query.isNotEmpty) {
|
||
_filteredChats = chatsToFilter.where((chat) {
|
||
final isSavedMessages = _isSavedMessages(chat);
|
||
if (isSavedMessages) {
|
||
return "избранное".contains(query);
|
||
}
|
||
final otherParticipantId = chat.participantIds.firstWhere(
|
||
(id) => id != chat.ownerId,
|
||
orElse: () => 0,
|
||
);
|
||
final contactName =
|
||
_contacts[otherParticipantId]?.name.toLowerCase() ?? '';
|
||
return contactName.contains(query);
|
||
}).toList();
|
||
|
||
_filteredChats.sort((a, b) {
|
||
final aIsSaved = _isSavedMessages(a);
|
||
final bIsSaved = _isSavedMessages(b);
|
||
if (aIsSaved && !bIsSaved) return -1;
|
||
if (!aIsSaved && bIsSaved) return 1;
|
||
|
||
if (aIsSaved && bIsSaved) {
|
||
if (a.id == 0) return -1;
|
||
if (b.id == 0) return 1;
|
||
}
|
||
return 0;
|
||
});
|
||
} else {
|
||
_filteredChats = [];
|
||
}
|
||
});
|
||
}
|
||
|
||
void _onSearchChanged() {
|
||
final query = _searchController.text;
|
||
_searchQuery = query;
|
||
|
||
_searchDebounceTimer?.cancel();
|
||
_searchDebounceTimer = Timer(const Duration(milliseconds: 300), () {
|
||
_performSearch();
|
||
});
|
||
}
|
||
|
||
void _onSearchFocusChanged() {
|
||
if (_searchFocusNode.hasFocus) {
|
||
_isSearchExpanded = true;
|
||
_searchAnimationController.forward();
|
||
} else if (_searchController.text.isEmpty) {
|
||
_isSearchExpanded = false;
|
||
_searchAnimationController.reverse();
|
||
}
|
||
}
|
||
|
||
void _performSearch() async {
|
||
if (_searchQuery.isEmpty) {
|
||
setState(() {
|
||
_searchResults.clear();
|
||
});
|
||
return;
|
||
}
|
||
|
||
setState(() {});
|
||
|
||
final results = <SearchResult>[];
|
||
final query = _searchQuery.toLowerCase();
|
||
|
||
for (final chat in _allChats) {
|
||
final isSavedMessages = _isSavedMessages(chat);
|
||
|
||
if (isSavedMessages) {
|
||
if ("избранное".contains(query)) {
|
||
results.add(
|
||
SearchResult(
|
||
chat: chat,
|
||
contact: _contacts[chat.ownerId],
|
||
matchedText: "Избранное",
|
||
matchType: 'name',
|
||
),
|
||
);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
final otherParticipantId = chat.participantIds.firstWhere(
|
||
(id) => id != chat.ownerId,
|
||
orElse: () => 0,
|
||
);
|
||
final contact = _contacts[otherParticipantId];
|
||
|
||
if (contact == null) continue;
|
||
|
||
final displayName = getContactDisplayName(
|
||
contactId: contact.id,
|
||
originalName: contact.name,
|
||
originalFirstName: contact.firstName,
|
||
originalLastName: contact.lastName,
|
||
);
|
||
|
||
if (displayName.toLowerCase().contains(query) ||
|
||
contact.name.toLowerCase().contains(query)) {
|
||
results.add(
|
||
SearchResult(
|
||
chat: chat,
|
||
contact: contact,
|
||
matchedText: displayName,
|
||
matchType: 'name',
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
|
||
if (contact.description != null &&
|
||
contact.description?.toLowerCase().contains(query) == true) {
|
||
results.add(
|
||
SearchResult(
|
||
chat: chat,
|
||
contact: contact,
|
||
matchedText: contact.description ?? '',
|
||
matchType: 'description',
|
||
),
|
||
);
|
||
continue;
|
||
}
|
||
|
||
if (chat.lastMessage.text.toLowerCase().contains(query) ||
|
||
(chat.lastMessage.text.contains("welcome.saved.dialog.message") &&
|
||
'привет избранные майор'.contains(query.toLowerCase()))) {
|
||
results.add(
|
||
SearchResult(
|
||
chat: chat,
|
||
contact: contact,
|
||
matchedText:
|
||
chat.lastMessage.text.contains("welcome.saved.dialog.message")
|
||
? 'Привет! Это твои избранные...'
|
||
: chat.lastMessage.text,
|
||
matchType: 'message',
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
List<SearchResult> filteredResults = results;
|
||
if (_searchFilter == 'recent') {
|
||
final weekAgo = DateTime.now().subtract(const Duration(days: 7));
|
||
filteredResults = results.where((result) {
|
||
final lastMessageTime = DateTime.fromMillisecondsSinceEpoch(
|
||
result.chat.lastMessage.time,
|
||
);
|
||
return lastMessageTime.isAfter(weekAgo);
|
||
}).toList();
|
||
}
|
||
|
||
setState(() {
|
||
_searchResults = filteredResults;
|
||
});
|
||
}
|
||
|
||
void _clearSearch() {
|
||
_searchController.clear();
|
||
_searchFocusNode.unfocus();
|
||
setState(() {
|
||
_searchQuery = '';
|
||
_searchResults.clear();
|
||
_isSearchExpanded = false;
|
||
});
|
||
_searchAnimationController.reverse();
|
||
}
|
||
|
||
void _loadChatsAndContacts() {
|
||
setState(() {
|
||
_chatsFuture = ApiService.instance.getChatsAndContacts(force: true);
|
||
});
|
||
|
||
_chatsFuture.then((data) {
|
||
if (mounted) {
|
||
final chats = data['chats'] as List;
|
||
final contacts = data['contacts'] as List;
|
||
final profileData = data['profile'];
|
||
|
||
_allChats = chats
|
||
.where((json) => json != null)
|
||
.map((json) => Chat.fromJson(json))
|
||
.toList();
|
||
|
||
_contacts.clear();
|
||
for (final contactJson in contacts) {
|
||
final contact = Contact.fromJson(contactJson);
|
||
_contacts[contact.id] = contact;
|
||
}
|
||
|
||
if (profileData != null) {
|
||
setState(() {
|
||
_myProfile = Profile.fromJson(profileData);
|
||
_isProfileLoading = false;
|
||
});
|
||
}
|
||
|
||
_filterChats();
|
||
}
|
||
});
|
||
}
|
||
|
||
Future<void> _loadChatOrder() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
final savedOrder = prefs.getStringList('chat_order');
|
||
if (savedOrder != null && savedOrder.isNotEmpty) {
|
||
final chatIds = savedOrder.map((id) => int.parse(id)).toList();
|
||
final orderedChats = <Chat>[];
|
||
final remainingChats = List<Chat>.from(_allChats);
|
||
|
||
for (final id in chatIds) {
|
||
final chatIndex = remainingChats.indexWhere((chat) => chat.id == id);
|
||
if (chatIndex != -1) {
|
||
orderedChats.add(remainingChats.removeAt(chatIndex));
|
||
}
|
||
}
|
||
|
||
orderedChats.addAll(remainingChats);
|
||
|
||
_allChats = orderedChats;
|
||
_filteredChats = List.from(_allChats);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadMissingContact(int contactId) async {
|
||
if (_loadingContactIds.contains(contactId) ||
|
||
_contacts.containsKey(contactId)) {
|
||
return;
|
||
}
|
||
|
||
_loadingContactIds.add(contactId);
|
||
|
||
try {
|
||
final contacts = await ApiService.instance.fetchContactsByIds([
|
||
contactId,
|
||
]);
|
||
if (contacts.isNotEmpty && mounted) {
|
||
setState(() {
|
||
_contacts[contactId] = contacts.first;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
print('Ошибка загрузки контакта $contactId: $e');
|
||
} finally {
|
||
_loadingContactIds.remove(contactId);
|
||
}
|
||
}
|
||
|
||
String _formatTimestamp(int timestamp) {
|
||
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||
final now = DateTime.now();
|
||
if (now.day == dt.day && now.month == dt.month && now.year == dt.year) {
|
||
return DateFormat('HH:mm', 'ru').format(dt);
|
||
} else {
|
||
final yesterday = now.subtract(const Duration(days: 1));
|
||
if (dt.day == yesterday.day &&
|
||
dt.month == yesterday.month &&
|
||
dt.year == yesterday.year) {
|
||
return 'Вчера';
|
||
} else {
|
||
return DateFormat('d MMM', 'ru').format(dt);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _openSferum() async {
|
||
try {
|
||
await ApiService.instance.waitUntilOnline();
|
||
final seq32 = ApiService.instance.sendAndTrackFullJsonRequest(
|
||
jsonEncode({
|
||
"ver": 11,
|
||
"cmd": 0,
|
||
"seq": 0,
|
||
"opcode": 32,
|
||
"payload": {
|
||
"contactIds": [2340831],
|
||
},
|
||
}),
|
||
);
|
||
|
||
final resp32 = await ApiService.instance.messages
|
||
.firstWhere((m) => m['seq'] == seq32)
|
||
.timeout(const Duration(seconds: 10));
|
||
|
||
final contacts = resp32['payload']['contacts'] as List;
|
||
if (contacts.isEmpty) {
|
||
throw Exception('Не удалось получить информацию о боте');
|
||
}
|
||
final webAppUrl = contacts[0]['webApp'] as String?;
|
||
if (webAppUrl == null) {
|
||
throw Exception('Бот не имеет веб-приложения');
|
||
}
|
||
|
||
int? chatId;
|
||
for (var chat in _allChats) {
|
||
if (chat.participantIds.contains(2340831)) {
|
||
chatId = chat.id;
|
||
break;
|
||
}
|
||
}
|
||
|
||
print('🔍 Найден chatId для бота Сферума: ${chatId ?? "не найден"}');
|
||
|
||
final seq160 = ApiService.instance.sendAndTrackFullJsonRequest(
|
||
jsonEncode({
|
||
"ver": 11,
|
||
"cmd": 0,
|
||
"seq": 0,
|
||
"opcode": 160,
|
||
"payload": {"botId": 2340831, "chatId": chatId ?? 0},
|
||
}),
|
||
);
|
||
|
||
print('📤 Отправлен opcode 160 с seq: $seq160');
|
||
|
||
final resp160 = await ApiService.instance.messages
|
||
.firstWhere((m) => m['seq'] == seq160)
|
||
.timeout(const Duration(seconds: 10));
|
||
|
||
print('📥 Получен ответ на opcode 160: ${resp160.toString()}');
|
||
|
||
final webUrl = resp160['payload']['url'] as String?;
|
||
if (webUrl == null) {
|
||
throw Exception('Не удалось получить URL веб-приложения');
|
||
}
|
||
|
||
print('🌐 URL веб-приложения: $webUrl');
|
||
|
||
if (mounted) {
|
||
_showSferumWebView(context, webUrl);
|
||
}
|
||
} catch (e, stackTrace) {
|
||
print('❌ Ошибка открытия Сферума: $e');
|
||
print('Stack trace: $stackTrace');
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Ошибка открытия Сферума: $e'),
|
||
backgroundColor: Colors.red,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _showSferumWebView(BuildContext context, String url) {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => SferumWebViewPanel(url: url),
|
||
fullscreenDialog: true,
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildConnectionScreen() {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32.0),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
CircularProgressIndicator(
|
||
strokeWidth: 3,
|
||
valueColor: AlwaysStoppedAnimation<Color>(colors.primary),
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
Text(
|
||
'Подключение',
|
||
style: TextStyle(
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w600,
|
||
color: colors.onSurface,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
|
||
Text(
|
||
'Устанавливаем соединение с сервером...',
|
||
style: TextStyle(fontSize: 14, color: colors.onSurfaceVariant),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
super.build(context);
|
||
|
||
final Widget bodyContent = Stack(
|
||
children: [
|
||
FutureBuilder<Map<String, dynamic>>(
|
||
future: _chatsFuture,
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||
return _buildConnectionScreen();
|
||
}
|
||
if (snapshot.hasError) {
|
||
return Center(
|
||
child: Text('Ошибка загрузки чатов: ${snapshot.error}'),
|
||
);
|
||
}
|
||
if (snapshot.hasData) {
|
||
if (_allChats.isEmpty) {
|
||
final chatListJson = snapshot.data!['chats'] as List;
|
||
final contactListJson = snapshot.data!['contacts'] as List;
|
||
_allChats = chatListJson
|
||
.map((json) => Chat.fromJson(json))
|
||
.toList();
|
||
final contacts = contactListJson.map(
|
||
(json) => Contact.fromJson(json),
|
||
);
|
||
_contacts = {for (var c in contacts) c.id: c};
|
||
|
||
final presence =
|
||
snapshot.data!['presence'] as Map<String, dynamic>?;
|
||
if (presence != null) {
|
||
print('Получен presence: $presence');
|
||
}
|
||
|
||
if (!_hasRequestedBlockedContacts) {
|
||
_hasRequestedBlockedContacts = true;
|
||
ApiService.instance.getBlockedContacts();
|
||
}
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_loadFolders(snapshot.data!);
|
||
});
|
||
|
||
_loadChatOrder().then((_) {
|
||
setState(() {
|
||
_filteredChats = List.from(_allChats);
|
||
});
|
||
});
|
||
}
|
||
if (_filteredChats.isEmpty && _allChats.isEmpty) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
if (_isSearchExpanded) {
|
||
return _buildSearchResults();
|
||
} else {
|
||
return Column(
|
||
children: [
|
||
_buildFolderTabs(),
|
||
Expanded(
|
||
child: TabBarView(
|
||
controller: _folderTabController,
|
||
children: _buildFolderPages(),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
return const Center(child: Text('Нет данных'));
|
||
},
|
||
),
|
||
|
||
if (!_isSearchExpanded) _buildDebugRefreshPanel(context),
|
||
],
|
||
);
|
||
|
||
if (widget.hasScaffold) {
|
||
return Builder(
|
||
builder: (context) {
|
||
return Scaffold(
|
||
appBar: _buildAppBar(context),
|
||
drawer: _buildAppDrawer(context),
|
||
body: Row(children: [Expanded(child: bodyContent)]),
|
||
floatingActionButton: FloatingActionButton(
|
||
onPressed: () {
|
||
_showAddMenu(context);
|
||
},
|
||
tooltip: 'Создать',
|
||
heroTag: 'create_menu',
|
||
child: const Icon(Icons.edit),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
} else {
|
||
return bodyContent;
|
||
}
|
||
}
|
||
|
||
Widget _buildAppDrawer(BuildContext context) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
final themeProvider = context.watch<ThemeProvider>();
|
||
final isDarkMode = themeProvider.themeMode == ThemeMode.dark;
|
||
|
||
return Drawer(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
FutureBuilder<List<Account>>(
|
||
future: _loadAccounts(),
|
||
builder: (context, accountsSnapshot) {
|
||
final accounts = accountsSnapshot.data ?? [];
|
||
final accountManager = AccountManager();
|
||
final currentAccount = accountManager.currentAccount;
|
||
final hasMultipleAccounts = accounts.length > 1;
|
||
|
||
return Column(
|
||
children: [
|
||
Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.only(
|
||
top: MediaQuery.of(context).padding.top + 16.0,
|
||
left: 16.0,
|
||
right: 16.0,
|
||
bottom: 16.0,
|
||
),
|
||
decoration: BoxDecoration(color: colors.primaryContainer),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 30, // Чуть крупнее
|
||
backgroundColor: colors.primary,
|
||
backgroundImage:
|
||
_isProfileLoading ||
|
||
_myProfile?.photoBaseUrl == null
|
||
? null
|
||
: NetworkImage(_myProfile!.photoBaseUrl!),
|
||
child: _isProfileLoading
|
||
? const SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white,
|
||
),
|
||
)
|
||
: (_myProfile?.photoBaseUrl == null
|
||
? Text(
|
||
_myProfile
|
||
?.displayName
|
||
.isNotEmpty ==
|
||
true
|
||
? _myProfile!.displayName[0]
|
||
.toUpperCase()
|
||
: '?',
|
||
style: TextStyle(
|
||
color: colors.onPrimary,
|
||
fontSize: 28, // Крупнее
|
||
),
|
||
)
|
||
: null),
|
||
),
|
||
IconButton(
|
||
icon: Icon(
|
||
isDarkMode
|
||
? Icons.brightness_7
|
||
: Icons.brightness_4, // Солнце / Луна
|
||
color: colors.onPrimaryContainer,
|
||
size: 26,
|
||
),
|
||
onPressed: () {
|
||
themeProvider.toggleTheme();
|
||
},
|
||
tooltip: isDarkMode
|
||
? 'Светлая тема'
|
||
: 'Темная тема',
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
|
||
Text(
|
||
_myProfile?.displayName ?? 'Загрузка...',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.bold,
|
||
color: colors.onPrimaryContainer,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
_myProfile?.formattedPhone ?? '',
|
||
style: TextStyle(
|
||
color: colors.onPrimaryContainer.withOpacity(
|
||
0.8,
|
||
),
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
),
|
||
InkWell(
|
||
onTap: () {
|
||
setState(() {
|
||
_isAccountsExpanded = !_isAccountsExpanded;
|
||
});
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(left: 8.0),
|
||
child: Icon(
|
||
_isAccountsExpanded
|
||
? Icons.expand_less
|
||
: Icons.expand_more,
|
||
color: colors.onPrimaryContainer,
|
||
size: 24,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
ClipRect(
|
||
child: AnimatedSize(
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeInOutCubic,
|
||
child: _isAccountsExpanded
|
||
? Column(
|
||
children: [
|
||
if (hasMultipleAccounts)
|
||
...accounts.map((account) {
|
||
final isCurrent =
|
||
account.id == currentAccount?.id;
|
||
return ListTile(
|
||
leading: CircleAvatar(
|
||
radius: 20,
|
||
backgroundColor: isCurrent
|
||
? colors.primary
|
||
: colors.surfaceVariant,
|
||
backgroundImage:
|
||
account.avatarUrl != null
|
||
? NetworkImage(account.avatarUrl!)
|
||
: null,
|
||
child: account.avatarUrl == null
|
||
? Text(
|
||
account.displayName.isNotEmpty
|
||
? account.displayName[0]
|
||
.toUpperCase()
|
||
: '?',
|
||
style: TextStyle(
|
||
color: isCurrent
|
||
? colors.onPrimary
|
||
: colors.onSurfaceVariant,
|
||
fontSize: 16,
|
||
),
|
||
)
|
||
: null,
|
||
),
|
||
title: Text(
|
||
account.displayName,
|
||
style: TextStyle(
|
||
fontWeight: isCurrent
|
||
? FontWeight.bold
|
||
: FontWeight.normal,
|
||
),
|
||
),
|
||
subtitle: account.displayPhone.isNotEmpty
|
||
? Text(account.displayPhone)
|
||
: null,
|
||
trailing: isCurrent
|
||
? Icon(
|
||
Icons.check_circle,
|
||
color: colors.primary,
|
||
size: 20,
|
||
)
|
||
: null,
|
||
onTap: isCurrent
|
||
? null
|
||
: () async {
|
||
Navigator.pop(context);
|
||
try {
|
||
await ApiService.instance
|
||
.switchAccount(account.id);
|
||
if (mounted) {
|
||
setState(() {
|
||
_isAccountsExpanded = false;
|
||
_loadMyProfile();
|
||
_chatsFuture = (() async {
|
||
try {
|
||
await ApiService
|
||
.instance
|
||
.waitUntilOnline();
|
||
return ApiService
|
||
.instance
|
||
.getChatsAndContacts();
|
||
} catch (e) {
|
||
print(
|
||
'Ошибка получения чатов: $e',
|
||
);
|
||
rethrow;
|
||
}
|
||
})();
|
||
});
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(
|
||
SnackBar(
|
||
content: Text(
|
||
'Ошибка переключения аккаунта: $e',
|
||
),
|
||
backgroundColor:
|
||
colors.error,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
}).toList(),
|
||
|
||
ListTile(
|
||
leading: const Icon(Icons.add_circle_outline),
|
||
title: const Text('Добавить аккаунт'),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) =>
|
||
const PhoneEntryScreen(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
)
|
||
: const SizedBox.shrink(),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
Expanded(
|
||
child: Column(
|
||
children: [
|
||
_buildAccountsSection(context, colors),
|
||
ListTile(
|
||
leading: const Icon(Icons.person_outline),
|
||
title: const Text('Мой профиль'),
|
||
onTap: () {
|
||
Navigator.pop(context); // Закрыть Drawer
|
||
_navigateToProfileEdit(); // Этот метод у вас уже есть
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.call_outlined),
|
||
title: const Text('Звонки'),
|
||
onTap: () {
|
||
Navigator.pop(context); // Закрыть Drawer
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => const CallsScreen(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.music_note),
|
||
title: const Text('Музыка'),
|
||
onTap: () {
|
||
Navigator.pop(context);
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => const MusicLibraryScreen(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.settings_outlined),
|
||
title: const Text('Настройки'),
|
||
onTap: () {
|
||
Navigator.pop(context); // Закрыть Drawer
|
||
|
||
final screenSize = MediaQuery.of(context).size;
|
||
final screenWidth = screenSize.width;
|
||
final screenHeight = screenSize.height;
|
||
final isDesktopOrTablet =
|
||
screenWidth >= 600 &&
|
||
screenHeight >= 800; // Планшеты и десктопы
|
||
|
||
print(
|
||
'Screen size: ${screenWidth}x${screenHeight}, isDesktopOrTablet: $isDesktopOrTablet',
|
||
);
|
||
|
||
if (isDesktopOrTablet) {
|
||
showDialog(
|
||
context: context,
|
||
barrierDismissible: true,
|
||
builder: (context) => SettingsScreen(
|
||
showBackToChats: true,
|
||
onBackToChats: () => Navigator.of(context).pop(),
|
||
myProfile: _myProfile,
|
||
isModal: true, // Включаем модальный режим
|
||
),
|
||
);
|
||
} else {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => SettingsScreen(
|
||
showBackToChats: true,
|
||
onBackToChats: () => Navigator.of(context).pop(),
|
||
myProfile: _myProfile,
|
||
isModal: false, // Отключаем модальный режим
|
||
),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
),
|
||
|
||
const Spacer(),
|
||
|
||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||
ListTile(
|
||
leading: Icon(Icons.logout, color: colors.error),
|
||
title: Text('Выйти', style: TextStyle(color: colors.error)),
|
||
onTap: () {
|
||
Navigator.pop(context); // Закрыть Drawer
|
||
_showLogoutDialog();
|
||
},
|
||
),
|
||
const SizedBox(height: 8), // Небольшой отступ снизу
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildAccountsSection(BuildContext context, ColorScheme colors) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
Future<List<Account>> _loadAccounts() async {
|
||
final accountManager = AccountManager();
|
||
await accountManager.initialize();
|
||
return accountManager.accounts;
|
||
}
|
||
|
||
Widget _buildSearchResults() {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
if (_searchQuery.isEmpty) {
|
||
return Column(
|
||
children: [
|
||
_buildRecentChatsIcons(),
|
||
const Divider(height: 1),
|
||
|
||
Expanded(
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.search,
|
||
size: 64,
|
||
color: colors.onSurfaceVariant.withOpacity(0.5),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Начните вводить для поиска',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
color: colors.onSurfaceVariant.withOpacity(0.7),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Или выберите чат из списка выше',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: colors.onSurfaceVariant.withOpacity(0.5),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
if (_searchResults.isEmpty) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.search_off,
|
||
size: 64,
|
||
color: colors.onSurfaceVariant.withOpacity(0.5),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Ничего не найдено',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
color: colors.onSurfaceVariant.withOpacity(0.7),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Попробуйте изменить поисковый запрос',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: colors.onSurfaceVariant.withOpacity(0.5),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return ListView.builder(
|
||
itemCount: _searchResults.length,
|
||
itemBuilder: (context, index) {
|
||
return _buildSearchResultItem(_searchResults[index]);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildSearchResultItem(SearchResult result) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
final chat = result.chat;
|
||
final contact = result.contact;
|
||
|
||
if (contact == null) return const SizedBox.shrink();
|
||
|
||
return ListTile(
|
||
onTap: () {
|
||
final bool isSavedMessages = _isSavedMessages(chat);
|
||
final bool isGroupChat = _isGroupChat(chat);
|
||
final bool isChannel = chat.type == 'CHANNEL';
|
||
final participantCount =
|
||
chat.participantsCount ?? chat.participantIds.length;
|
||
|
||
final Contact contactToUse = isSavedMessages
|
||
? Contact(
|
||
id: chat.id,
|
||
name: "Избранное",
|
||
firstName: "",
|
||
lastName: "",
|
||
photoBaseUrl: null,
|
||
description: null,
|
||
isBlocked: false,
|
||
isBlockedByMe: false,
|
||
)
|
||
: contact;
|
||
|
||
if (widget.onChatSelected != null) {
|
||
widget.onChatSelected!(
|
||
chat,
|
||
contactToUse,
|
||
isGroupChat,
|
||
isChannel,
|
||
participantCount,
|
||
);
|
||
} else {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => ChatScreen(
|
||
chatId: chat.id,
|
||
contact: contactToUse,
|
||
myId: chat.ownerId,
|
||
isGroupChat: isGroupChat,
|
||
isChannel: isChannel,
|
||
participantCount: participantCount,
|
||
onChatUpdated: () {
|
||
print('Chat updated, но не обновляем список чатов...');
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
leading: CircleAvatar(
|
||
radius: 24,
|
||
backgroundColor: colors.primaryContainer,
|
||
backgroundImage: contact.photoBaseUrl != null
|
||
? NetworkImage(contact.photoBaseUrl ?? '')
|
||
: null,
|
||
child: contact.photoBaseUrl == null
|
||
? Text(
|
||
contact.name.isNotEmpty ? contact.name[0].toUpperCase() : '?',
|
||
style: TextStyle(color: colors.onPrimaryContainer),
|
||
)
|
||
: null,
|
||
),
|
||
title: _buildHighlightedText(
|
||
getContactDisplayName(
|
||
contactId: contact.id,
|
||
originalName: contact.name,
|
||
originalFirstName: contact.firstName,
|
||
originalLastName: contact.lastName,
|
||
),
|
||
result.matchedText,
|
||
),
|
||
subtitle: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (result.matchType == 'message')
|
||
chat.lastMessage.text.contains("welcome.saved.dialog.message")
|
||
? _buildWelcomeMessage()
|
||
: _buildSearchMessagePreview(chat, result.matchedText),
|
||
if (result.matchType == 'description')
|
||
_buildHighlightedText(
|
||
contact.description ?? '',
|
||
result.matchedText,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
_formatTimestamp(chat.lastMessage.time),
|
||
style: TextStyle(color: colors.onSurfaceVariant, fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
trailing: chat.newMessages > 0
|
||
? CircleAvatar(
|
||
radius: 10,
|
||
backgroundColor: colors.primary,
|
||
child: Text(
|
||
chat.newMessages.toString(),
|
||
style: TextStyle(color: colors.onPrimary, fontSize: 12),
|
||
),
|
||
)
|
||
: null,
|
||
);
|
||
}
|
||
|
||
Widget _buildHighlightedText(String text, String query) {
|
||
if (query.isEmpty) return Text(text);
|
||
|
||
final lowerText = text.toLowerCase();
|
||
final lowerQuery = query.toLowerCase();
|
||
final index = lowerText.indexOf(lowerQuery);
|
||
|
||
if (index == -1) return Text(text);
|
||
|
||
return RichText(
|
||
text: TextSpan(
|
||
children: [
|
||
TextSpan(
|
||
text: text.substring(0, index),
|
||
style: const TextStyle(color: Colors.black),
|
||
),
|
||
TextSpan(
|
||
text: text.substring(index, index + query.length),
|
||
style: TextStyle(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
TextSpan(
|
||
text: text.substring(index + query.length),
|
||
style: const TextStyle(color: Colors.black),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildRecentChatsIcons() {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
final recentChats = _allChats.take(15).toList();
|
||
|
||
return Container(
|
||
height: 100,
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: ListView.builder(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
itemCount: recentChats.length,
|
||
itemBuilder: (context, index) {
|
||
final chat = recentChats[index];
|
||
final bool isGroupChat = _isGroupChat(chat);
|
||
final bool isSavedMessages = _isSavedMessages(chat);
|
||
|
||
final Contact? contact;
|
||
int? otherParticipantId;
|
||
if (isSavedMessages) {
|
||
contact = _contacts[chat.ownerId];
|
||
} else if (isGroupChat) {
|
||
contact = null;
|
||
} else {
|
||
otherParticipantId = chat.participantIds.firstWhere(
|
||
(id) => id != chat.ownerId,
|
||
orElse: () => 0,
|
||
);
|
||
contact = _contacts[otherParticipantId];
|
||
}
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: 12),
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
final bool isChannel = chat.type == 'CHANNEL';
|
||
String title;
|
||
if (isGroupChat) {
|
||
title = chat.title?.isNotEmpty == true
|
||
? chat.title!
|
||
: "Группа";
|
||
} else if (isSavedMessages) {
|
||
title = "Избранное";
|
||
} else if (contact != null) {
|
||
title = getContactDisplayName(
|
||
contactId: contact.id,
|
||
originalName: contact.name,
|
||
originalFirstName: contact.firstName,
|
||
originalLastName: contact.lastName,
|
||
);
|
||
} else if (chat.title?.isNotEmpty == true) {
|
||
title = chat.title!;
|
||
} else {
|
||
title = "ID ${otherParticipantId ?? 0}";
|
||
if (otherParticipantId != null && otherParticipantId != 0) {
|
||
_loadMissingContact(otherParticipantId);
|
||
}
|
||
}
|
||
final String? avatarUrl = isGroupChat
|
||
? chat.baseIconUrl
|
||
: (isSavedMessages ? null : contact?.photoBaseUrl);
|
||
final participantCount =
|
||
chat.participantsCount ?? chat.participantIds.length;
|
||
|
||
final Contact contactFallback =
|
||
contact ??
|
||
Contact(
|
||
id: chat.id,
|
||
name: title,
|
||
firstName: "",
|
||
lastName: "",
|
||
photoBaseUrl: avatarUrl,
|
||
description: isChannel ? chat.description : null,
|
||
isBlocked: false,
|
||
isBlockedByMe: false,
|
||
);
|
||
|
||
if (widget.onChatSelected != null) {
|
||
widget.onChatSelected!(
|
||
chat,
|
||
contactFallback,
|
||
isGroupChat,
|
||
isChannel,
|
||
participantCount,
|
||
);
|
||
} else {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => ChatScreen(
|
||
chatId: chat.id,
|
||
contact: contactFallback,
|
||
myId: chat.ownerId,
|
||
isGroupChat: isGroupChat,
|
||
isChannel: isChannel,
|
||
participantCount: participantCount,
|
||
onChatUpdated: () {
|
||
_loadChatsAndContacts();
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Stack(
|
||
children: [
|
||
isSavedMessages || isGroupChat
|
||
? CircleAvatar(
|
||
radius: 28,
|
||
backgroundColor: colors.primaryContainer,
|
||
backgroundImage:
|
||
isGroupChat && chat.baseIconUrl != null
|
||
? NetworkImage(chat.baseIconUrl ?? '')
|
||
: null,
|
||
child:
|
||
isSavedMessages ||
|
||
(isGroupChat && chat.baseIconUrl == null)
|
||
? Icon(
|
||
isSavedMessages
|
||
? Icons.bookmark
|
||
: Icons.group,
|
||
color: colors.onPrimaryContainer,
|
||
size: 20,
|
||
)
|
||
: null,
|
||
)
|
||
: contact != null
|
||
? ContactAvatarWidget(
|
||
contactId: contact.id,
|
||
originalAvatarUrl: contact.photoBaseUrl,
|
||
radius: 28,
|
||
fallbackText: contact.name.isNotEmpty
|
||
? contact.name[0].toUpperCase()
|
||
: '?',
|
||
backgroundColor: colors.primaryContainer,
|
||
)
|
||
: CircleAvatar(
|
||
radius: 28,
|
||
backgroundColor: colors.primaryContainer,
|
||
child: const Text('?'),
|
||
),
|
||
|
||
if (chat.newMessages > 0)
|
||
Positioned(
|
||
right: 0,
|
||
top: 0,
|
||
child: Container(
|
||
width: 16,
|
||
height: 16,
|
||
decoration: BoxDecoration(
|
||
color: colors.primary,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: colors.surface,
|
||
width: 2,
|
||
),
|
||
),
|
||
child: Center(
|
||
child: Text(
|
||
chat.newMessages > 9
|
||
? '9+'
|
||
: chat.newMessages.toString(),
|
||
style: TextStyle(
|
||
color: colors.onPrimary,
|
||
fontSize: 8,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
SizedBox(
|
||
width: 56,
|
||
child: isGroupChat
|
||
? Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.group,
|
||
size: 10,
|
||
color: colors.onSurface,
|
||
),
|
||
const SizedBox(width: 2),
|
||
Expanded(
|
||
child: Text(
|
||
chat.title?.isNotEmpty == true
|
||
? chat.title!
|
||
: "Группа (${chat.participantIds.length})",
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: colors.onSurface,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
: Text(
|
||
isSavedMessages
|
||
? "Избранное"
|
||
: (contact?.name ??
|
||
(chat.title?.isNotEmpty == true
|
||
? chat.title!
|
||
: (otherParticipantId != null
|
||
? 'ID $otherParticipantId'
|
||
: 'ID 0'))),
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: colors.onSurface,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
void _onFolderTabChanged() {
|
||
if (!_folderTabController.indexIsChanging) {
|
||
final index = _folderTabController.index;
|
||
final folderId = index == 0 ? null : _folders[index - 1].id;
|
||
|
||
if (_selectedFolderId != folderId) {
|
||
setState(() {
|
||
_selectedFolderId = folderId;
|
||
});
|
||
_filterChats();
|
||
}
|
||
}
|
||
}
|
||
|
||
List<Widget> _buildFolderPages() {
|
||
final List<Widget> pages = [
|
||
_buildChatsListForFolder(null),
|
||
..._folders.map((folder) => _buildChatsListForFolder(folder)),
|
||
];
|
||
|
||
return pages;
|
||
}
|
||
|
||
Widget _buildChatsListForFolder(ChatFolder? folder) {
|
||
List<Chat> chatsForFolder = _allChats;
|
||
|
||
if (folder != null) {
|
||
chatsForFolder = _allChats
|
||
.where((chat) => _chatBelongsToFolder(chat, folder))
|
||
.toList();
|
||
}
|
||
|
||
chatsForFolder.sort((a, b) {
|
||
final aIsSaved = _isSavedMessages(a);
|
||
final bIsSaved = _isSavedMessages(b);
|
||
if (aIsSaved && !bIsSaved) return -1;
|
||
if (!aIsSaved && bIsSaved) return 1;
|
||
if (aIsSaved && bIsSaved) {
|
||
if (a.id == 0) return -1;
|
||
if (b.id == 0) return 1;
|
||
}
|
||
return 0;
|
||
});
|
||
|
||
final query = _searchController.text.toLowerCase();
|
||
if (query.isNotEmpty) {
|
||
chatsForFolder = chatsForFolder.where((chat) {
|
||
final isSavedMessages = _isSavedMessages(chat);
|
||
if (isSavedMessages) {
|
||
return "избранное".contains(query);
|
||
}
|
||
final otherParticipantId = chat.participantIds.firstWhere(
|
||
(id) => id != chat.ownerId,
|
||
orElse: () => 0,
|
||
);
|
||
final contactName =
|
||
_contacts[otherParticipantId]?.name.toLowerCase() ?? '';
|
||
return contactName.contains(query);
|
||
}).toList();
|
||
}
|
||
|
||
if (chatsForFolder.isEmpty) {
|
||
return Center(
|
||
child: Text(
|
||
folder == null ? 'Нет чатов' : 'В этой папке пока нет чатов',
|
||
style: TextStyle(
|
||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return ListView.builder(
|
||
itemCount: chatsForFolder.length,
|
||
itemBuilder: (context, index) {
|
||
return _buildChatListItem(chatsForFolder[index], index, folder);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildFolderTabs() {
|
||
if (_folderTabController.length <= 1) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
final List<Widget> tabs = [
|
||
Tab(
|
||
child: GestureDetector(
|
||
onLongPress: () {},
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [Text('Все чаты', style: TextStyle(fontSize: 14))],
|
||
),
|
||
),
|
||
),
|
||
..._folders.map(
|
||
(folder) => Tab(
|
||
child: GestureDetector(
|
||
onLongPress: () {
|
||
_showFolderEditMenu(folder);
|
||
},
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (folder.emoji != null) ...[
|
||
Text(folder.emoji!, style: const TextStyle(fontSize: 16)),
|
||
const SizedBox(width: 6),
|
||
],
|
||
Text(folder.title, style: const TextStyle(fontSize: 14)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
];
|
||
|
||
return Container(
|
||
height: 48,
|
||
decoration: BoxDecoration(
|
||
color: colors.surface,
|
||
border: Border(
|
||
bottom: BorderSide(color: colors.outline.withOpacity(0.2), width: 1),
|
||
),
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _folders.length <= 3
|
||
? Center(
|
||
child: TabBar(
|
||
controller: _folderTabController,
|
||
isScrollable: false,
|
||
tabAlignment: TabAlignment.center,
|
||
labelColor: colors.primary,
|
||
unselectedLabelColor: colors.onSurfaceVariant,
|
||
indicator: UnderlineTabIndicator(
|
||
borderSide: BorderSide(
|
||
width: 3,
|
||
color: colors.primary,
|
||
),
|
||
insets: const EdgeInsets.symmetric(horizontal: 16),
|
||
),
|
||
indicatorSize: TabBarIndicatorSize.label,
|
||
labelStyle: const TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 14,
|
||
),
|
||
unselectedLabelStyle: const TextStyle(
|
||
fontWeight: FontWeight.normal,
|
||
fontSize: 14,
|
||
),
|
||
dividerColor: Colors.transparent,
|
||
tabs: tabs,
|
||
onTap: (index) {},
|
||
),
|
||
)
|
||
: Transform.translate(
|
||
offset: const Offset(-42, 0),
|
||
child: TabBar(
|
||
controller: _folderTabController,
|
||
isScrollable: true,
|
||
labelColor: colors.primary,
|
||
unselectedLabelColor: colors.onSurfaceVariant,
|
||
indicator: UnderlineTabIndicator(
|
||
borderSide: BorderSide(
|
||
width: 3,
|
||
color: colors.primary,
|
||
),
|
||
insets: const EdgeInsets.symmetric(horizontal: 16),
|
||
),
|
||
indicatorSize: TabBarIndicatorSize.label,
|
||
labelStyle: const TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 14,
|
||
),
|
||
unselectedLabelStyle: const TextStyle(
|
||
fontWeight: FontWeight.normal,
|
||
fontSize: 14,
|
||
),
|
||
dividerColor: Colors.transparent,
|
||
tabs: tabs,
|
||
onTap: (index) {},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Positioned(
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.add, size: 20),
|
||
onPressed: _showCreateFolderDialog,
|
||
tooltip: 'Создать папку',
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
constraints: const BoxConstraints(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showCreateFolderDialog() {
|
||
final TextEditingController titleController = TextEditingController();
|
||
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Создать папку'),
|
||
content: TextField(
|
||
controller: titleController,
|
||
autofocus: true,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Название папки',
|
||
hintText: 'Введите название',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
onSubmitted: (value) {
|
||
if (value.trim().isNotEmpty) {
|
||
ApiService.instance.createFolder(value.trim());
|
||
Navigator.of(context).pop();
|
||
}
|
||
},
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () {
|
||
final title = titleController.text.trim();
|
||
if (title.isNotEmpty) {
|
||
ApiService.instance.createFolder(title);
|
||
Navigator.of(context).pop();
|
||
}
|
||
},
|
||
child: const Text('Создать'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _showFolderEditMenu(ChatFolder folder) async {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
await showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (context) {
|
||
return DraggableScrollableSheet(
|
||
initialChildSize: 0.4,
|
||
minChildSize: 0.3,
|
||
maxChildSize: 0.6,
|
||
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, bottom: 8),
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: colors.onSurfaceVariant.withOpacity(0.4),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 12,
|
||
),
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: colors.outline.withOpacity(0.2),
|
||
width: 1,
|
||
),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
if (folder.emoji != null) ...[
|
||
Text(
|
||
folder.emoji!,
|
||
style: const TextStyle(fontSize: 24),
|
||
),
|
||
const SizedBox(width: 12),
|
||
],
|
||
Expanded(
|
||
child: Text(
|
||
folder.title,
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
color: colors.onSurface,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close),
|
||
color: colors.onSurfaceVariant,
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(child: _buildFolderEditMenuContent(folder, context)),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildFolderEditMenuContent(ChatFolder folder, BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
child: Text(
|
||
'Действия',
|
||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
const Divider(),
|
||
ListTile(
|
||
leading: const Icon(Icons.add),
|
||
title: const Text('Выбрать чаты'),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_showAddChatsToFolderDialog(folder);
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.edit),
|
||
title: const Text('Переименовать'),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_showRenameFolderDialog(folder);
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.delete, color: Colors.red),
|
||
title: const Text(
|
||
'Удалить папку',
|
||
style: TextStyle(color: Colors.red),
|
||
),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_showDeleteFolderDialog(folder);
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showRenameFolderDialog(ChatFolder folder) {
|
||
final TextEditingController titleController = TextEditingController(
|
||
text: folder.title,
|
||
);
|
||
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Переименовать папку'),
|
||
content: TextField(
|
||
controller: titleController,
|
||
autofocus: true,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Название папки',
|
||
hintText: 'Введите название',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
onSubmitted: (value) {
|
||
if (value.trim().isNotEmpty) {
|
||
ApiService.instance.updateFolder(
|
||
folder.id,
|
||
title: value.trim(),
|
||
include: folder.include,
|
||
filters: folder.filters,
|
||
);
|
||
Navigator.of(context).pop();
|
||
}
|
||
},
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () {
|
||
final title = titleController.text.trim();
|
||
if (title.isNotEmpty) {
|
||
ApiService.instance.updateFolder(
|
||
folder.id,
|
||
title: title,
|
||
include: folder.include,
|
||
filters: folder.filters,
|
||
);
|
||
Navigator.of(context).pop();
|
||
}
|
||
},
|
||
child: const Text('Сохранить'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showDeleteFolderDialog(ChatFolder folder) {
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Удалить папку'),
|
||
content: Text(
|
||
'Вы уверены, что хотите удалить папку "${folder.title}"?',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
ApiService.instance.deleteFolder(folder.id);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Папка "${folder.title}" удалена'),
|
||
duration: const Duration(seconds: 2),
|
||
),
|
||
);
|
||
},
|
||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||
child: const Text('Удалить'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _showMessagePreview(Chat chat, ChatFolder? currentFolder) async {
|
||
await MessagePreviewDialog.show(
|
||
context,
|
||
chat,
|
||
_contacts,
|
||
_myProfile,
|
||
null,
|
||
(context) => _buildChatMenuContent(chat, currentFolder, context),
|
||
);
|
||
}
|
||
|
||
Widget _buildChatMenuContent(
|
||
Chat chat,
|
||
ChatFolder? currentFolder,
|
||
BuildContext context,
|
||
) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
child: Text(
|
||
'Действия',
|
||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
const Divider(),
|
||
if (currentFolder == null && _folders.isNotEmpty)
|
||
ListTile(
|
||
leading: const Icon(Icons.folder),
|
||
title: const Text('Добавить в папку'),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_showFolderSelectionMenu(chat);
|
||
},
|
||
),
|
||
ListTile(
|
||
leading: const Icon(Icons.mark_chat_read),
|
||
title: const Text('Настройки чтения'),
|
||
subtitle: const Text('Настроить чтение сообщений для этого чата'),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_showReadSettingsDialog(chat);
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showFolderSelectionMenu(Chat chat) {
|
||
if (_folders.isEmpty) return;
|
||
|
||
showModalBottomSheet(
|
||
context: context,
|
||
builder: (context) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: colors.onSurfaceVariant.withOpacity(0.4),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
child: Text(
|
||
'Выберите папку',
|
||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
const Divider(),
|
||
..._folders.map((folder) {
|
||
return ListTile(
|
||
leading: folder.emoji != null
|
||
? Text(
|
||
folder.emoji!,
|
||
style: const TextStyle(fontSize: 24),
|
||
)
|
||
: const Icon(Icons.folder),
|
||
title: Text(folder.title),
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_addChatToFolder(chat, folder);
|
||
},
|
||
);
|
||
}),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<void> _showReadSettingsDialog(Chat chat) async {
|
||
final settingsService = ChatReadSettingsService.instance;
|
||
final currentSettings = await settingsService.getSettings(chat.id);
|
||
final theme = context.read<ThemeProvider>();
|
||
|
||
if (!mounted) return;
|
||
|
||
showModalBottomSheet(
|
||
context: context,
|
||
builder: (context) {
|
||
return _ReadSettingsDialogContent(
|
||
chat: chat,
|
||
initialSettings: currentSettings,
|
||
globalReadOnAction: theme.debugReadOnAction,
|
||
globalReadOnEnter: theme.debugReadOnEnter,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _addChatToFolder(Chat chat, ChatFolder folder) {
|
||
final currentInclude = folder.include ?? [];
|
||
|
||
if (currentInclude.contains(chat.id)) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Чат уже находится в папке "${folder.title}"'),
|
||
duration: const Duration(seconds: 2),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
final newInclude = List<int>.from(currentInclude)..add(chat.id);
|
||
|
||
ApiService.instance.updateFolder(
|
||
folder.id,
|
||
title: folder.title,
|
||
include: newInclude,
|
||
filters: folder.filters,
|
||
);
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('Чат добавлен в папку "${folder.title}"'),
|
||
duration: const Duration(seconds: 2),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showAddChatsToFolderDialog(ChatFolder folder) {
|
||
final currentInclude = folder.include ?? [];
|
||
|
||
// Получаем все чаты, кроме "Избранного" (chat.id == 0)
|
||
final allAvailableChats = _allChats.where((chat) {
|
||
return chat.id != 0;
|
||
}).toList();
|
||
|
||
// Сортируем: сначала чаты, которые уже в папке, затем остальные
|
||
final sortedChats = List<Chat>.from(allAvailableChats);
|
||
sortedChats.sort((a, b) {
|
||
final aInFolder = currentInclude.contains(a.id);
|
||
final bInFolder = currentInclude.contains(b.id);
|
||
if (aInFolder && !bInFolder) return -1;
|
||
if (!aInFolder && bInFolder) return 1;
|
||
return 0;
|
||
});
|
||
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (context) {
|
||
return _AddChatsToFolderDialog(
|
||
folder: folder,
|
||
availableChats: sortedChats,
|
||
contacts: _contacts,
|
||
onAddChats: (selectedChats) {
|
||
_updateFolderChats(selectedChats, folder);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _updateFolderChats(List<Chat> selectedChats, ChatFolder folder) {
|
||
final currentInclude = folder.include ?? [];
|
||
final selectedChatIds = selectedChats.map((chat) => chat.id).toSet();
|
||
|
||
// Создаем новый список include только с выбранными чатами
|
||
final newInclude = selectedChatIds.toList();
|
||
|
||
// Подсчитываем изменения
|
||
final addedCount = newInclude
|
||
.where((id) => !currentInclude.contains(id))
|
||
.length;
|
||
final removedCount = currentInclude
|
||
.where((id) => !selectedChatIds.contains(id))
|
||
.length;
|
||
|
||
ApiService.instance.updateFolder(
|
||
folder.id,
|
||
title: folder.title,
|
||
include: newInclude,
|
||
filters: folder.filters,
|
||
);
|
||
|
||
String message;
|
||
if (addedCount > 0 && removedCount > 0) {
|
||
message = 'Папка "${folder.title}" обновлена';
|
||
} else if (addedCount > 0) {
|
||
message = addedCount == 1
|
||
? 'Чат добавлен в папку "${folder.title}"'
|
||
: '$addedCount чатов добавлено в папку "${folder.title}"';
|
||
} else if (removedCount > 0) {
|
||
message = removedCount == 1
|
||
? 'Чат удален из папки "${folder.title}"'
|
||
: '$removedCount чатов удалено из папки "${folder.title}"';
|
||
} else {
|
||
message = 'Изменения сохранены';
|
||
}
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
|
||
);
|
||
}
|
||
|
||
Widget _buildDebugRefreshPanel(BuildContext context) {
|
||
final theme = context.watch<ThemeProvider>();
|
||
if (!theme.debugShowChatsRefreshPanel) return const SizedBox.shrink();
|
||
final bool hasBottomBar = theme.debugShowBottomBar;
|
||
final double bottomPadding = hasBottomBar ? 80.0 : 20.0;
|
||
final colors = Theme.of(context).colorScheme;
|
||
return Positioned(
|
||
left: 12,
|
||
right: 12,
|
||
bottom: bottomPadding,
|
||
child: SafeArea(
|
||
top: false,
|
||
child: Center(
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 520),
|
||
child: Material(
|
||
color: colors.surface.withOpacity(0.95),
|
||
elevation: 6,
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(12),
|
||
onTap: () async {
|
||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_allChats.clear();
|
||
_filteredChats.clear();
|
||
_chatsFuture = ApiService.instance.getChatsAndContacts(
|
||
force: true,
|
||
);
|
||
});
|
||
}
|
||
});
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 10,
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.refresh),
|
||
const SizedBox(width: 8),
|
||
const Text('Обновить список чатов'),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildStatusRow({
|
||
required Key key,
|
||
required String text,
|
||
required Widget icon,
|
||
}) {
|
||
return Row(
|
||
key: key,
|
||
children: [
|
||
SizedBox(width: 18, height: 18, child: Center(child: icon)),
|
||
const SizedBox(width: 8),
|
||
Flexible(
|
||
child: Text(
|
||
text,
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildCurrentTitleWidget() {
|
||
final colors = Theme.of(context).colorScheme;
|
||
final onSurfaceVariant = colors.onSurfaceVariant;
|
||
|
||
if (_connectionStatus == 'connecting') {
|
||
return _buildStatusRow(
|
||
key: const ValueKey('status_connecting'),
|
||
text: 'Подключение...',
|
||
icon: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: onSurfaceVariant,
|
||
),
|
||
);
|
||
}
|
||
|
||
if (_connectionStatus == 'authorizing') {
|
||
return _buildStatusRow(
|
||
key: const ValueKey('status_authorizing'),
|
||
text: 'Авторизация...',
|
||
icon: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: onSurfaceVariant,
|
||
),
|
||
);
|
||
}
|
||
|
||
if (_connectionStatus == 'disconnected' ||
|
||
_connectionStatus == 'Все серверы недоступны') {
|
||
return _buildStatusRow(
|
||
key: const ValueKey('status_error'),
|
||
text: 'Нет сети',
|
||
icon: Icon(Icons.cloud_off, size: 18, color: colors.error),
|
||
);
|
||
}
|
||
|
||
if (_isProfileLoading) {
|
||
return _buildStatusRow(
|
||
key: const ValueKey('status_loading'),
|
||
text: 'Загрузка...',
|
||
icon: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: onSurfaceVariant,
|
||
),
|
||
);
|
||
}
|
||
|
||
return Text(
|
||
_myProfile?.displayName ?? 'Komet',
|
||
key: const ValueKey('status_username'),
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||
);
|
||
}
|
||
|
||
AppBar _buildAppBar(BuildContext context) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
return AppBar(
|
||
titleSpacing: 4.0,
|
||
|
||
leading: _isSearchExpanded
|
||
? IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
onPressed: _clearSearch,
|
||
)
|
||
: Builder(
|
||
builder: (context) {
|
||
return IconButton(
|
||
icon: const Icon(Icons.menu_rounded),
|
||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||
tooltip: 'Меню',
|
||
);
|
||
},
|
||
),
|
||
|
||
title: _isSearchExpanded
|
||
? _buildSearchField(colors)
|
||
: Row(
|
||
children: [
|
||
Expanded(
|
||
child: AnimatedSwitcher(
|
||
duration: const Duration(milliseconds: 300),
|
||
transitionBuilder:
|
||
(Widget child, Animation<double> animation) {
|
||
return FadeTransition(
|
||
opacity: animation,
|
||
child: child,
|
||
);
|
||
},
|
||
layoutBuilder: (currentChild, previousChildren) {
|
||
return Stack(
|
||
alignment: Alignment.centerLeft,
|
||
children: [
|
||
...previousChildren,
|
||
if (currentChild != null) currentChild,
|
||
],
|
||
);
|
||
},
|
||
child: _buildCurrentTitleWidget(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: _isSearchExpanded
|
||
? [
|
||
if (_searchQuery.isNotEmpty)
|
||
Container(
|
||
margin: const EdgeInsets.only(left: 4),
|
||
child: IconButton(
|
||
icon: const Icon(Icons.clear),
|
||
onPressed: () {
|
||
_searchController.clear();
|
||
},
|
||
),
|
||
),
|
||
Container(
|
||
margin: const EdgeInsets.only(left: 4),
|
||
child: IconButton(
|
||
icon: const Icon(Icons.filter_list),
|
||
onPressed: _showSearchFilters,
|
||
),
|
||
),
|
||
]
|
||
: [
|
||
IconButton(
|
||
icon: Image.asset(
|
||
'assets/images/spermum.png',
|
||
width: 28,
|
||
height: 28,
|
||
),
|
||
onPressed: _openSferum,
|
||
tooltip: 'Сферум',
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.download),
|
||
onPressed: () {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => const DownloadsScreen(),
|
||
),
|
||
);
|
||
},
|
||
tooltip: 'Загрузки',
|
||
),
|
||
InkWell(
|
||
onTap: () {
|
||
setState(() {
|
||
_isSearchExpanded = true;
|
||
});
|
||
_searchAnimationController.forward();
|
||
_searchFocusNode.requestFocus();
|
||
},
|
||
|
||
onLongPress: () {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => const UserIdLookupScreen(),
|
||
),
|
||
);
|
||
},
|
||
customBorder: const CircleBorder(),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(8.0),
|
||
child: const Icon(Icons.search),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildWelcomeMessage() {
|
||
return Text(
|
||
'Привет! Это твои избранные. Все написанное сюда попадёт прямиком к дяде Майору.',
|
||
style: TextStyle(
|
||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
fontSize: 12,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
);
|
||
}
|
||
|
||
Widget _buildSearchField(ColorScheme colors) {
|
||
return SizedBox(
|
||
height: 40,
|
||
child: TextField(
|
||
controller: _searchController,
|
||
focusNode: _searchFocusNode,
|
||
autofocus: true,
|
||
decoration: InputDecoration(
|
||
contentPadding: EdgeInsets.zero,
|
||
prefixIcon: Icon(
|
||
Icons.search,
|
||
color: colors.onSurfaceVariant,
|
||
size: 18,
|
||
),
|
||
hintText: 'Поиск в чатах...',
|
||
hintStyle: TextStyle(color: colors.onSurfaceVariant),
|
||
filled: true,
|
||
fillColor: colors.surfaceContainerHighest.withOpacity(0.3),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(20),
|
||
borderSide: BorderSide.none,
|
||
),
|
||
),
|
||
style: const TextStyle(fontSize: 16),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _navigateToProfileEdit() {
|
||
if (_myProfile != null) {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => ManageAccountScreen(myProfile: _myProfile!),
|
||
),
|
||
);
|
||
} else {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('Профиль еще не загружен')));
|
||
}
|
||
}
|
||
|
||
void _showLogoutDialog() {
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) {
|
||
return AlertDialog(
|
||
title: const Text('Выход из аккаунта'),
|
||
content: const Text('Вы действительно хотите выйти из аккаунта?'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () async {
|
||
Navigator.pop(context);
|
||
await _logout();
|
||
},
|
||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||
child: const Text('Выйти'),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<void> _logout() async {
|
||
try {
|
||
if (mounted) {
|
||
Navigator.of(context).pushAndRemoveUntil(
|
||
MaterialPageRoute(builder: (context) => const PhoneEntryScreen()),
|
||
(route) => false,
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text('Ошибка при выходе: $e')));
|
||
}
|
||
}
|
||
}
|
||
|
||
void _showSearchFilters() {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
builder: (context) => Container(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'Фильтры поиска',
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
),
|
||
const SizedBox(height: 16),
|
||
_buildFilterOption('all', 'Все чаты', Icons.chat_bubble_outline),
|
||
_buildFilterOption('recent', 'Недавние', Icons.access_time),
|
||
_buildFilterOption(
|
||
'channels',
|
||
'Каналы',
|
||
Icons.broadcast_on_personal,
|
||
),
|
||
_buildFilterOption('groups', 'Группы', Icons.group),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFilterOption(String value, String title, IconData icon) {
|
||
final isSelected = _searchFilter == value;
|
||
return ListTile(
|
||
leading: Icon(
|
||
icon,
|
||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||
),
|
||
title: Text(title),
|
||
trailing: isSelected ? const Icon(Icons.check) : null,
|
||
onTap: () {
|
||
setState(() {
|
||
_searchFilter = value;
|
||
});
|
||
Navigator.pop(context);
|
||
_performSearch();
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildLastMessagePreview(Chat chat) {
|
||
final message = chat.lastMessage;
|
||
|
||
if (message.attaches.isNotEmpty) {
|
||
for (final attach in message.attaches) {
|
||
final type = attach['_type'];
|
||
if (type == 'CALL' || type == 'call') {
|
||
return _buildCallPreview(attach, message, chat);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (message.text.isEmpty && message.attaches.isNotEmpty) {
|
||
return Text('Вложение', maxLines: 1, overflow: TextOverflow.ellipsis);
|
||
}
|
||
|
||
return Text(message.text, maxLines: 1, overflow: TextOverflow.ellipsis);
|
||
}
|
||
|
||
Widget _buildSearchMessagePreview(Chat chat, String matchedText) {
|
||
final message = chat.lastMessage;
|
||
|
||
if (message.attaches.isNotEmpty) {
|
||
final callAttachments = message.attaches.where((attach) {
|
||
final type = attach['_type'];
|
||
return type == 'CALL' || type == 'call';
|
||
}).toList();
|
||
|
||
if (callAttachments.isNotEmpty) {
|
||
return _buildCallPreview(callAttachments.first, message, chat);
|
||
}
|
||
}
|
||
|
||
if (message.text.isEmpty && message.attaches.isNotEmpty) {
|
||
return Text('Вложение', maxLines: 1, overflow: TextOverflow.ellipsis);
|
||
}
|
||
|
||
return _buildHighlightedText(message.text, matchedText);
|
||
}
|
||
|
||
Widget _buildCallPreview(
|
||
Map<String, dynamic> callAttach,
|
||
Message message,
|
||
Chat chat,
|
||
) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
final hangupType = callAttach['hangupType'] as String? ?? '';
|
||
final callType = callAttach['callType'] as String? ?? 'AUDIO';
|
||
final duration = callAttach['duration'] as int? ?? 0;
|
||
|
||
String callText;
|
||
IconData callIcon;
|
||
Color? callColor;
|
||
|
||
switch (hangupType) {
|
||
case 'HUNGUP':
|
||
final minutes = duration ~/ 60000;
|
||
final seconds = (duration % 60000) ~/ 1000;
|
||
final durationText = minutes > 0
|
||
? '$minutes:${seconds.toString().padLeft(2, '0')}'
|
||
: '$seconds сек';
|
||
|
||
final callTypeText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок';
|
||
callText = '$callTypeText, $durationText';
|
||
callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call;
|
||
callColor = colors.primary;
|
||
break;
|
||
|
||
case 'MISSED':
|
||
final callTypeText = callType == 'VIDEO'
|
||
? 'Пропущенный видеозвонок'
|
||
: 'Пропущенный звонок';
|
||
callText = callTypeText;
|
||
callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_missed;
|
||
callColor = colors.error;
|
||
break;
|
||
|
||
case 'CANCELED':
|
||
final callTypeText = callType == 'VIDEO'
|
||
? 'Видеозвонок отменен'
|
||
: 'Звонок отменен';
|
||
callText = callTypeText;
|
||
callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_end;
|
||
callColor = colors.onSurfaceVariant;
|
||
break;
|
||
|
||
case 'REJECTED':
|
||
final callTypeText = callType == 'VIDEO'
|
||
? 'Видеозвонок отклонен'
|
||
: 'Звонок отклонен';
|
||
callText = callTypeText;
|
||
callIcon = callType == 'VIDEO' ? Icons.videocam_off : Icons.call_end;
|
||
callColor = colors.onSurfaceVariant;
|
||
break;
|
||
|
||
default:
|
||
callText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок';
|
||
callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call;
|
||
callColor = colors.onSurfaceVariant;
|
||
break;
|
||
}
|
||
|
||
return Row(
|
||
children: [
|
||
Icon(callIcon, size: 16, color: callColor),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: Text(
|
||
callText,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(color: callColor),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildChatListItem(Chat chat, int index, ChatFolder? currentFolder) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
final bool isSavedMessages = _isSavedMessages(chat);
|
||
final bool isGroupChat = _isGroupChat(chat);
|
||
final bool isChannel = chat.type == 'CHANNEL';
|
||
|
||
Contact? contact;
|
||
String title;
|
||
final String? avatarUrl;
|
||
IconData leadingIcon;
|
||
|
||
if (isSavedMessages) {
|
||
contact = _contacts[chat.ownerId];
|
||
title = "Избранное";
|
||
leadingIcon = Icons.bookmark;
|
||
avatarUrl = null;
|
||
} else if (isChannel) {
|
||
contact = null;
|
||
title = chat.title ?? "Канал";
|
||
leadingIcon = Icons.campaign;
|
||
avatarUrl = chat.baseIconUrl;
|
||
} else if (isGroupChat) {
|
||
contact = null;
|
||
title = chat.title?.isNotEmpty == true
|
||
? chat.title!
|
||
: "Группа (${chat.participantIds.length} участников)";
|
||
leadingIcon = Icons.group;
|
||
avatarUrl = chat.baseIconUrl;
|
||
} else {
|
||
final myId = chat.ownerId;
|
||
final otherParticipantId = chat.participantIds.firstWhere(
|
||
(id) => id != myId,
|
||
orElse: () => myId,
|
||
);
|
||
contact = _contacts[otherParticipantId];
|
||
|
||
if (contact != null) {
|
||
title = getContactDisplayName(
|
||
contactId: contact.id,
|
||
originalName: contact.name,
|
||
originalFirstName: contact.firstName,
|
||
originalLastName: contact.lastName,
|
||
);
|
||
} else if (chat.title?.isNotEmpty == true) {
|
||
title = chat.title!;
|
||
} else {
|
||
title = "ID $otherParticipantId";
|
||
_loadMissingContact(otherParticipantId);
|
||
}
|
||
avatarUrl = contact?.photoBaseUrl;
|
||
leadingIcon = Icons.person;
|
||
}
|
||
|
||
return ListTile(
|
||
key: ValueKey(chat.id),
|
||
onTap: () {
|
||
final theme = context.read<ThemeProvider>();
|
||
if (theme.debugReadOnEnter) {
|
||
final chatIndex = _allChats.indexWhere((c) => c.id == chat.id);
|
||
if (chatIndex != -1) {
|
||
final oldChat = _allChats[chatIndex];
|
||
if (oldChat.newMessages > 0) {
|
||
final updatedChat = oldChat.copyWith(newMessages: 0);
|
||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_allChats[chatIndex] = updatedChat;
|
||
_filterChats();
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
final Contact contactFallback = isSavedMessages
|
||
? Contact(
|
||
id: chat.id,
|
||
name: "Избранное",
|
||
firstName: "",
|
||
lastName: "",
|
||
photoBaseUrl: null,
|
||
description: null,
|
||
isBlocked: false,
|
||
isBlockedByMe: false,
|
||
)
|
||
: contact ??
|
||
Contact(
|
||
id: chat.id,
|
||
name: title,
|
||
firstName: "",
|
||
lastName: "",
|
||
photoBaseUrl: avatarUrl,
|
||
description: isChannel ? chat.description : null,
|
||
isBlocked: false,
|
||
isBlockedByMe: false,
|
||
);
|
||
|
||
final participantCount =
|
||
chat.participantsCount ?? chat.participantIds.length;
|
||
|
||
if (widget.onChatSelected != null) {
|
||
widget.onChatSelected!(
|
||
chat,
|
||
contactFallback,
|
||
isGroupChat,
|
||
isChannel,
|
||
participantCount,
|
||
);
|
||
} else {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => ChatScreen(
|
||
chatId: chat.id,
|
||
contact: contactFallback,
|
||
myId: chat.ownerId,
|
||
isGroupChat: isGroupChat,
|
||
isChannel: isChannel,
|
||
participantCount: participantCount,
|
||
onChatUpdated: () {
|
||
print('Chat updated, но не обновляем список чатов...');
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
leading: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
GestureDetector(
|
||
onLongPress: () => _showMessagePreview(chat, currentFolder),
|
||
child: CircleAvatar(
|
||
radius: 24,
|
||
backgroundColor: colors.primaryContainer,
|
||
|
||
backgroundImage: avatarUrl != null
|
||
? NetworkImage(avatarUrl)
|
||
: null,
|
||
|
||
child: avatarUrl == null
|
||
? (isSavedMessages || isGroupChat || isChannel)
|
||
? Icon(leadingIcon, color: colors.onPrimaryContainer)
|
||
: Text(
|
||
title.isNotEmpty ? title[0].toUpperCase() : '?',
|
||
style: TextStyle(color: colors.onPrimaryContainer),
|
||
)
|
||
: null,
|
||
),
|
||
),
|
||
Positioned(
|
||
right: -4,
|
||
bottom: -2,
|
||
child: _typingChats.contains(chat.id)
|
||
? _TypingDots(color: colors.primary, size: 20)
|
||
: (_onlineChats.contains(chat.id)
|
||
? _PresenceDot(isOnline: true, size: 12)
|
||
: const SizedBox.shrink()),
|
||
),
|
||
],
|
||
),
|
||
title: Row(
|
||
children: [
|
||
if (isGroupChat) ...[
|
||
Icon(Icons.group, size: 16, color: colors.onSurfaceVariant),
|
||
const SizedBox(width: 6),
|
||
],
|
||
Expanded(
|
||
child: Text(
|
||
title,
|
||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
subtitle: chat.lastMessage.text.contains("welcome.saved.dialog.message")
|
||
? _buildWelcomeMessage()
|
||
: _buildLastMessagePreview(chat),
|
||
trailing: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
_formatTimestamp(chat.lastMessage.time),
|
||
style: TextStyle(
|
||
color: chat.newMessages > 0
|
||
? colors.primary
|
||
: colors.onSurfaceVariant,
|
||
fontSize: 12,
|
||
),
|
||
),
|
||
if (chat.newMessages > 0 && !isSavedMessages) ...[
|
||
const SizedBox(height: 4),
|
||
CircleAvatar(
|
||
radius: 10,
|
||
backgroundColor: colors.primary,
|
||
child: Text(
|
||
chat.newMessages.toString(),
|
||
style: TextStyle(color: colors.onPrimary, fontSize: 12),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_apiSubscription?.cancel();
|
||
_connectionStatusSubscription?.cancel();
|
||
_connectionStateSubscription?.cancel();
|
||
_searchController.dispose();
|
||
_searchFocusNode.dispose();
|
||
_searchDebounceTimer?.cancel();
|
||
_searchAnimationController.dispose();
|
||
_folderTabController.dispose();
|
||
super.dispose();
|
||
}
|
||
}
|
||
|
||
class _TypingDots extends StatefulWidget {
|
||
final Color color;
|
||
final double size;
|
||
const _TypingDots({required this.color, this.size = 18});
|
||
@override
|
||
State<_TypingDots> createState() => _TypingDotsState();
|
||
}
|
||
|
||
class _TypingDotsState extends State<_TypingDots>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _c;
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_c = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 900),
|
||
)..repeat();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_c.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final w = widget.size;
|
||
return SizedBox(
|
||
width: w,
|
||
height: w * 0.6,
|
||
child: AnimatedBuilder(
|
||
animation: _c,
|
||
builder: (context, _) {
|
||
final t = _c.value;
|
||
double a(int i) => 0.3 + 0.7 * ((t + i / 3) % 1.0);
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: List.generate(3, (i) {
|
||
return Opacity(
|
||
opacity: a(i),
|
||
child: Container(
|
||
width: w * 0.22,
|
||
height: w * 0.22,
|
||
decoration: BoxDecoration(
|
||
color: widget.color,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PresenceDot extends StatelessWidget {
|
||
final bool isOnline;
|
||
final double size;
|
||
const _PresenceDot({required this.isOnline, this.size = 10});
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
return Container(
|
||
width: size,
|
||
height: size,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: isOnline ? colors.primary : colors.onSurfaceVariant,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class CallsScreen extends StatelessWidget {
|
||
const CallsScreen({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Звонки')),
|
||
body: const Center(child: Text('Звонки скоро будут доступны')),
|
||
);
|
||
}
|
||
}
|
||
|
||
class SferumWebViewPanel extends StatefulWidget {
|
||
final String url;
|
||
|
||
const SferumWebViewPanel({super.key, required this.url});
|
||
|
||
@override
|
||
State<SferumWebViewPanel> createState() => _SferumWebViewPanelState();
|
||
}
|
||
|
||
class _SferumWebViewPanelState extends State<SferumWebViewPanel> {
|
||
bool _isLoading = true;
|
||
InAppWebViewController? _webViewController;
|
||
|
||
Future<void> _checkCanGoBack() async {}
|
||
|
||
Future<void> _goBack() async {
|
||
if (_webViewController != null && await _webViewController!.canGoBack()) {
|
||
await _webViewController!.goBack();
|
||
_checkCanGoBack();
|
||
} else {
|
||
if (mounted) {
|
||
Navigator.of(context).pop();
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
return Scaffold(
|
||
backgroundColor: colors.surface,
|
||
appBar: AppBar(
|
||
backgroundColor: colors.surface,
|
||
elevation: 0,
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
onPressed: _goBack,
|
||
),
|
||
title: Row(
|
||
children: [
|
||
Image.asset('assets/images/spermum.png', width: 28, height: 28),
|
||
const SizedBox(width: 12),
|
||
const Text(
|
||
'Сферум',
|
||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.close),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
tooltip: 'Закрыть',
|
||
),
|
||
],
|
||
),
|
||
body: Stack(
|
||
children: [
|
||
if (!Platform.isLinux)
|
||
InAppWebView(
|
||
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
|
||
initialSettings: InAppWebViewSettings(
|
||
javaScriptEnabled: true,
|
||
transparentBackground: true,
|
||
useShouldOverrideUrlLoading: true,
|
||
useOnLoadResource: false,
|
||
useOnDownloadStart: false,
|
||
cacheEnabled: true,
|
||
verticalScrollBarEnabled: true,
|
||
horizontalScrollBarEnabled: true,
|
||
supportZoom: false,
|
||
disableVerticalScroll: false,
|
||
disableHorizontalScroll: false,
|
||
allowsInlineMediaPlayback: true,
|
||
mediaPlaybackRequiresUserGesture: false,
|
||
allowsBackForwardNavigationGestures: true,
|
||
useHybridComposition: true,
|
||
supportMultipleWindows: false,
|
||
javaScriptCanOpenWindowsAutomatically: false,
|
||
),
|
||
onWebViewCreated: (controller) {
|
||
_webViewController = controller;
|
||
},
|
||
onCreateWindow: (controller, createWindowAction) async {
|
||
final uri = createWindowAction.request.url;
|
||
print('🪟 Попытка открыть новое окно: $uri');
|
||
if (uri != null) {
|
||
await controller.loadUrl(urlRequest: URLRequest(url: uri));
|
||
}
|
||
return true;
|
||
},
|
||
shouldOverrideUrlLoading: (controller, navigationAction) async {
|
||
final uri = navigationAction.request.url;
|
||
final navigationType = navigationAction.navigationType;
|
||
print(
|
||
'🔗 Попытка перехода по ссылке: $uri (тип: $navigationType)',
|
||
);
|
||
|
||
if (navigationType == NavigationType.LINK_ACTIVATED) {
|
||
return NavigationActionPolicy.ALLOW;
|
||
}
|
||
|
||
return NavigationActionPolicy.ALLOW;
|
||
},
|
||
onLoadStart: (controller, url) async {
|
||
print('🌐 WebView начало загрузки: $url');
|
||
setState(() {
|
||
_isLoading = true;
|
||
});
|
||
try {
|
||
await controller.evaluateJavascript(
|
||
source: '''
|
||
// Переопределяем window.open сразу
|
||
if (window.open.toString().indexOf('native code') === -1) {
|
||
var originalOpen = window.open;
|
||
window.open = function(url, name, features) {
|
||
if (url && typeof url === 'string') {
|
||
window.location.href = url;
|
||
return null;
|
||
}
|
||
return originalOpen.apply(this, arguments);
|
||
};
|
||
}
|
||
''',
|
||
);
|
||
} catch (e) {
|
||
print(
|
||
'⚠️ Ошибка при выполнении JavaScript в onLoadStart: $e',
|
||
);
|
||
}
|
||
},
|
||
onLoadStop: (controller, url) async {
|
||
print('✅ WebView загрузка завершена: $url');
|
||
setState(() {
|
||
_isLoading = false;
|
||
});
|
||
_checkCanGoBack();
|
||
try {
|
||
await controller.evaluateJavascript(
|
||
source: '''
|
||
// Включаем прокрутку
|
||
document.body.style.overflow = 'auto';
|
||
document.documentElement.style.overflow = 'auto';
|
||
document.body.style.webkitOverflowScrolling = 'touch';
|
||
document.body.style.position = 'relative';
|
||
document.documentElement.style.position = 'relative';
|
||
|
||
// Перехватываем все клики по ссылкам и принудительно открываем в том же окне
|
||
(function() {
|
||
// Функция для обработки ссылок
|
||
function processLink(link) {
|
||
if (link && link.tagName === 'A') {
|
||
var href = link.getAttribute('href');
|
||
if (href && !href.startsWith('javascript:') && !href.startsWith('mailto:')) {
|
||
// Убираем target="_blank" если есть
|
||
link.removeAttribute('target');
|
||
// Добавляем обработчик клика
|
||
link.addEventListener('click', function(e) {
|
||
var href = this.getAttribute('href');
|
||
if (href && !href.startsWith('javascript:') && !href.startsWith('mailto:')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
window.location.href = href;
|
||
return false;
|
||
}
|
||
}, true);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Обрабатываем все существующие ссылки
|
||
function processAllLinks() {
|
||
var links = document.querySelectorAll('a');
|
||
for (var i = 0; i < links.length; i++) {
|
||
processLink(links[i]);
|
||
}
|
||
}
|
||
|
||
// Обрабатываем ссылки при загрузке
|
||
processAllLinks();
|
||
|
||
// Перехватываем клики на уровне document
|
||
document.addEventListener('click', function(e) {
|
||
var target = e.target;
|
||
// Находим ближайшую ссылку
|
||
while (target && target.tagName !== 'A' && target !== document.body) {
|
||
target = target.parentElement;
|
||
}
|
||
if (target && target.tagName === 'A') {
|
||
var href = target.getAttribute('href');
|
||
if (href && !href.startsWith('javascript:') && !href.startsWith('mailto:')) {
|
||
// Убираем target если есть
|
||
target.removeAttribute('target');
|
||
// Открываем в том же окне
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
window.location.href = href;
|
||
return false;
|
||
}
|
||
}
|
||
}, true);
|
||
|
||
// Отслеживаем динамически добавляемые ссылки
|
||
var observer = new MutationObserver(function(mutations) {
|
||
mutations.forEach(function(mutation) {
|
||
mutation.addedNodes.forEach(function(node) {
|
||
if (node.nodeType === 1) { // Element node
|
||
if (node.tagName === 'A') {
|
||
processLink(node);
|
||
}
|
||
// Обрабатываем ссылки внутри добавленного элемента
|
||
var links = node.querySelectorAll ? node.querySelectorAll('a') : [];
|
||
for (var i = 0; i < links.length; i++) {
|
||
processLink(links[i]);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// Начинаем наблюдение за изменениями в DOM
|
||
observer.observe(document.body, {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
|
||
// Переопределяем window.open чтобы открывать в том же окне
|
||
var originalOpen = window.open;
|
||
window.open = function(url, name, features) {
|
||
if (url && typeof url === 'string') {
|
||
window.location.href = url;
|
||
return null;
|
||
}
|
||
return originalOpen.apply(this, arguments);
|
||
};
|
||
})();
|
||
''',
|
||
);
|
||
} catch (e) {
|
||
print('⚠️ Ошибка при выполнении JavaScript: $e');
|
||
}
|
||
},
|
||
onReceivedError: (controller, request, error) {
|
||
print('❌ WebView ошибка: ${error.description} (${error.type})');
|
||
},
|
||
onConsoleMessage: (controller, consoleMessage) {
|
||
print('📝 Console: ${consoleMessage.message}');
|
||
},
|
||
),
|
||
if (Platform.isLinux)
|
||
Container(
|
||
color: colors.surface,
|
||
child: const Center(
|
||
child: Text(
|
||
'Сферум временно не доступен на линуксе,\nмы думаем как это исправить.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
),
|
||
),
|
||
|
||
if (_isLoading && !Platform.isLinux)
|
||
Container(
|
||
color: colors.surface,
|
||
child: const Center(child: CircularProgressIndicator()),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AddChatsToFolderDialog extends StatefulWidget {
|
||
final ChatFolder folder;
|
||
final List<Chat> availableChats;
|
||
final Map<int, Contact> contacts;
|
||
final Function(List<Chat>) onAddChats;
|
||
|
||
const _AddChatsToFolderDialog({
|
||
required this.folder,
|
||
required this.availableChats,
|
||
required this.contacts,
|
||
required this.onAddChats,
|
||
});
|
||
|
||
@override
|
||
State<_AddChatsToFolderDialog> createState() =>
|
||
_AddChatsToFolderDialogState();
|
||
}
|
||
|
||
class _AddChatsToFolderDialogState extends State<_AddChatsToFolderDialog> {
|
||
late final Set<int> _selectedChatIds;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final currentInclude = widget.folder.include ?? [];
|
||
_selectedChatIds = currentInclude.toSet();
|
||
}
|
||
|
||
bool _isGroupChat(Chat chat) {
|
||
return chat.type == 'CHAT' || chat.participantIds.length > 2;
|
||
}
|
||
|
||
bool _isSavedMessages(Chat chat) {
|
||
return chat.id == 0;
|
||
}
|
||
|
||
void _toggleChatSelection(Chat chat) {
|
||
setState(() {
|
||
if (_selectedChatIds.contains(chat.id)) {
|
||
_selectedChatIds.remove(chat.id);
|
||
} else {
|
||
_selectedChatIds.add(chat.id);
|
||
}
|
||
});
|
||
}
|
||
|
||
void _addSelectedChats() {
|
||
final selectedChats = widget.availableChats
|
||
.where((chat) => _selectedChatIds.contains(chat.id))
|
||
.toList();
|
||
|
||
Navigator.of(context).pop();
|
||
widget.onAddChats(selectedChats);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
|
||
return DraggableScrollableSheet(
|
||
initialChildSize: 0.7,
|
||
minChildSize: 0.5,
|
||
maxChildSize: 0.9,
|
||
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, bottom: 8),
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: colors.onSurfaceVariant.withOpacity(0.4),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 12,
|
||
),
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
bottom: BorderSide(
|
||
color: colors.outline.withOpacity(0.2),
|
||
width: 1,
|
||
),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
if (widget.folder.emoji != null) ...[
|
||
Text(
|
||
widget.folder.emoji!,
|
||
style: const TextStyle(fontSize: 24),
|
||
),
|
||
const SizedBox(width: 12),
|
||
],
|
||
Expanded(
|
||
child: Text(
|
||
'Выбрать чаты для "${widget.folder.title}"',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
color: colors.onSurface,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.close),
|
||
color: colors.onSurfaceVariant,
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
onPressed: () {
|
||
Navigator.of(context).pop();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView.builder(
|
||
controller: scrollController,
|
||
itemCount: widget.availableChats.length,
|
||
itemBuilder: (context, index) {
|
||
final chat = widget.availableChats[index];
|
||
final isGroupChat = _isGroupChat(chat);
|
||
final isChannel = chat.type == 'CHANNEL';
|
||
final isSavedMessages = _isSavedMessages(chat);
|
||
|
||
Contact? contact;
|
||
String title;
|
||
String? avatarUrl;
|
||
IconData leadingIcon;
|
||
|
||
if (isSavedMessages) {
|
||
contact = widget.contacts[chat.ownerId];
|
||
title = "Избранное";
|
||
leadingIcon = Icons.bookmark;
|
||
avatarUrl = null;
|
||
} else if (isChannel) {
|
||
contact = null;
|
||
title = chat.title ?? "Канал";
|
||
leadingIcon = Icons.campaign;
|
||
avatarUrl = chat.baseIconUrl;
|
||
} else if (isGroupChat) {
|
||
contact = null;
|
||
title = chat.title?.isNotEmpty == true
|
||
? chat.title!
|
||
: "Группа";
|
||
leadingIcon = Icons.group;
|
||
avatarUrl = chat.baseIconUrl;
|
||
} else {
|
||
final myId = chat.ownerId;
|
||
final otherParticipantId = chat.participantIds.firstWhere(
|
||
(id) => id != myId,
|
||
orElse: () => myId,
|
||
);
|
||
contact = widget.contacts[otherParticipantId];
|
||
|
||
if (contact != null) {
|
||
title = getContactDisplayName(
|
||
contactId: contact.id,
|
||
originalName: contact.name,
|
||
originalFirstName: contact.firstName,
|
||
originalLastName: contact.lastName,
|
||
);
|
||
} else if (chat.title?.isNotEmpty == true) {
|
||
title = chat.title!;
|
||
} else {
|
||
title = "ID $otherParticipantId";
|
||
}
|
||
avatarUrl = contact?.photoBaseUrl;
|
||
leadingIcon = Icons.person;
|
||
}
|
||
|
||
final isSelected = _selectedChatIds.contains(chat.id);
|
||
|
||
return ListTile(
|
||
leading: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
CircleAvatar(
|
||
radius: 24,
|
||
backgroundColor: colors.primaryContainer,
|
||
backgroundImage: avatarUrl != null
|
||
? NetworkImage(avatarUrl)
|
||
: null,
|
||
child: avatarUrl == null
|
||
? (isSavedMessages || isGroupChat || isChannel)
|
||
? Icon(
|
||
leadingIcon,
|
||
color: colors.onPrimaryContainer,
|
||
)
|
||
: Text(
|
||
title.isNotEmpty
|
||
? title[0].toUpperCase()
|
||
: '?',
|
||
style: TextStyle(
|
||
color: colors.onPrimaryContainer,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
)
|
||
: null,
|
||
),
|
||
],
|
||
),
|
||
title: Text(title, style: const TextStyle(fontSize: 16)),
|
||
subtitle: isGroupChat && chat.participantIds.length > 2
|
||
? Text(
|
||
'${chat.participantIds.length} участников',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
)
|
||
: null,
|
||
trailing: Checkbox(
|
||
value: isSelected,
|
||
onChanged: (_) => _toggleChatSelection(chat),
|
||
),
|
||
onTap: () => _toggleChatSelection(chat),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
border: Border(
|
||
top: BorderSide(
|
||
color: colors.outline.withOpacity(0.2),
|
||
width: 1,
|
||
),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
_selectedChatIds.isEmpty
|
||
? 'Выберите чаты'
|
||
: 'Выбрано: ${_selectedChatIds.length}',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
FilledButton(
|
||
onPressed: _addSelectedChats,
|
||
child: const Text('Сохранить'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ReadSettingsDialogContent extends StatefulWidget {
|
||
final Chat chat;
|
||
final ChatReadSettings? initialSettings;
|
||
final bool globalReadOnAction;
|
||
final bool globalReadOnEnter;
|
||
|
||
const _ReadSettingsDialogContent({
|
||
required this.chat,
|
||
required this.initialSettings,
|
||
required this.globalReadOnAction,
|
||
required this.globalReadOnEnter,
|
||
});
|
||
|
||
@override
|
||
State<_ReadSettingsDialogContent> createState() =>
|
||
_ReadSettingsDialogContentState();
|
||
}
|
||
|
||
class _ReadSettingsDialogContentState
|
||
extends State<_ReadSettingsDialogContent> {
|
||
ChatReadSettings? _settings;
|
||
bool _useDefault = true;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_settings = widget.initialSettings;
|
||
_useDefault = widget.initialSettings == null;
|
||
}
|
||
|
||
String _getSelectedOption() {
|
||
if (_useDefault) {
|
||
return 'default';
|
||
}
|
||
|
||
if (_settings == null) {
|
||
return 'default';
|
||
}
|
||
|
||
if (_settings!.disabled) {
|
||
return 'disabled';
|
||
} else if (_settings!.readOnAction && _settings!.readOnEnter) {
|
||
return 'both';
|
||
} else if (_settings!.readOnAction) {
|
||
return 'action';
|
||
} else if (_settings!.readOnEnter) {
|
||
return 'enter';
|
||
}
|
||
return 'default';
|
||
}
|
||
|
||
Future<void> _setOption(String option) async {
|
||
setState(() {
|
||
if (option == 'default') {
|
||
_useDefault = true;
|
||
_settings = null;
|
||
} else {
|
||
_useDefault = false;
|
||
switch (option) {
|
||
case 'disabled':
|
||
_settings = ChatReadSettings(
|
||
readOnAction: false,
|
||
readOnEnter: false,
|
||
disabled: true,
|
||
);
|
||
break;
|
||
case 'action':
|
||
_settings = ChatReadSettings(
|
||
readOnAction: true,
|
||
readOnEnter: false,
|
||
disabled: false,
|
||
);
|
||
break;
|
||
case 'enter':
|
||
_settings = ChatReadSettings(
|
||
readOnAction: false,
|
||
readOnEnter: true,
|
||
disabled: false,
|
||
);
|
||
break;
|
||
case 'both':
|
||
default:
|
||
_settings = ChatReadSettings(
|
||
readOnAction: true,
|
||
readOnEnter: true,
|
||
disabled: false,
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
if (_useDefault || _settings == null) {
|
||
await ChatReadSettingsService.instance.resetSettings(widget.chat.id);
|
||
} else {
|
||
await ChatReadSettingsService.instance.saveSettings(
|
||
widget.chat.id,
|
||
_settings!,
|
||
);
|
||
}
|
||
|
||
if (mounted) {
|
||
Navigator.of(context).pop();
|
||
}
|
||
}
|
||
|
||
String _getDefaultDescription() {
|
||
final action = widget.globalReadOnAction ? 'при действиях' : '';
|
||
final enter = widget.globalReadOnEnter ? 'при входе' : '';
|
||
|
||
if (action.isNotEmpty && enter.isNotEmpty) {
|
||
return 'Чтение $action и $enter';
|
||
} else if (action.isNotEmpty) {
|
||
return 'Чтение $action';
|
||
} else if (enter.isNotEmpty) {
|
||
return 'Чтение $enter';
|
||
}
|
||
return 'Чтение отключено';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colors = Theme.of(context).colorScheme;
|
||
final selectedOption = _getSelectedOption();
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: colors.onSurfaceVariant.withOpacity(0.4),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
child: Text(
|
||
'Настройки чтения сообщений',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
color: colors.onSurface,
|
||
),
|
||
),
|
||
),
|
||
const Divider(),
|
||
Flexible(
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
RadioListTile<String>(
|
||
title: const Text('По умолчанию'),
|
||
subtitle: Text(_getDefaultDescription()),
|
||
value: 'default',
|
||
groupValue: selectedOption,
|
||
onChanged: (value) => _setOption(value!),
|
||
),
|
||
RadioListTile<String>(
|
||
title: const Text('Отключить чтение'),
|
||
subtitle: const Text(
|
||
'Сообщения не будут отмечаться как прочитанные',
|
||
),
|
||
value: 'disabled',
|
||
groupValue: selectedOption,
|
||
onChanged: (value) => _setOption(value!),
|
||
),
|
||
RadioListTile<String>(
|
||
title: const Text('Чтение при действиях'),
|
||
subtitle: const Text(
|
||
'Отмечать прочитанным при отправке сообщения',
|
||
),
|
||
value: 'action',
|
||
groupValue: selectedOption,
|
||
onChanged: (value) => _setOption(value!),
|
||
),
|
||
RadioListTile<String>(
|
||
title: const Text('Чтение при входе в чат'),
|
||
subtitle: const Text(
|
||
'Отмечать прочитанным при открытии чата',
|
||
),
|
||
value: 'enter',
|
||
groupValue: selectedOption,
|
||
onChanged: (value) => _setOption(value!),
|
||
),
|
||
RadioListTile<String>(
|
||
title: const Text('Чтение при действиях и при входе'),
|
||
subtitle: const Text(
|
||
'Отмечать прочитанным при отправке и при открытии',
|
||
),
|
||
value: 'both',
|
||
groupValue: selectedOption,
|
||
onChanged: (value) => _setOption(value!),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|