diff --git a/README.md b/README.md index d3b7d5b..2e103a2 100644 --- a/README.md +++ b/README.md @@ -5,5 +5,11 @@ ## How to build? ### This is app built on flutter, use flutter guide ## How to countibute? -### Join the dev team +### Create a fork, do everything +### And create pull requeste +### Make sure your commits looks like: +fix: something went worng when user... +add: search by id +edit: refactored something +Other actions should marked as "other:" and discribes what you did diff --git a/android/app/src/main/kotlin/com/gwid/app/gwid/MainActivity.kt b/android/app/src/main/kotlin/com/github/kometteam/komet/MainActivity.kt similarity index 100% rename from android/app/src/main/kotlin/com/gwid/app/gwid/MainActivity.kt rename to android/app/src/main/kotlin/com/github/kometteam/komet/MainActivity.kt diff --git a/assets/images/spermum.png b/assets/images/spermum.png new file mode 100644 index 0000000..d42ecc1 Binary files /dev/null and b/assets/images/spermum.png differ diff --git a/assets/images/spermum_but_dark.webp b/assets/images/spermum_but_dark.webp new file mode 100644 index 0000000..c643a24 Binary files /dev/null and b/assets/images/spermum_but_dark.webp differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/api_service.dart b/lib/api_service.dart index 468068c..1067415 100644 --- a/lib/api_service.dart +++ b/lib/api_service.dart @@ -17,20 +17,17 @@ class ApiService { ApiService._privateConstructor(); static final ApiService instance = ApiService._privateConstructor(); - int? _userId; late int _sessionId; int _actionId = 1; bool _isColdStartSent = false; late int _lastActionTime; - bool _isAppInForeground = true; final List _wsUrls = ['wss://ws-api.oneme.ru:443/websocket']; int _currentUrlIndex = 0; - List get wsUrls => _wsUrls; int get currentUrlIndex => _currentUrlIndex; IOWebSocketChannel? _channel; @@ -38,12 +35,10 @@ class ApiService { Timer? _pingTimer; int _seq = 0; - final StreamController _contactUpdatesController = StreamController.broadcast(); Stream get contactUpdates => _contactUpdatesController.stream; - final StreamController _errorController = StreamController.broadcast(); Stream get errorStream => _errorController.stream; @@ -52,14 +47,12 @@ class ApiService { Stream get reconnectionComplete => _reconnectionCompleteController.stream; - final Map _presenceData = {}; String? authToken; String? userId; String? get token => authToken; - String? _currentPasswordTrackId; String? _currentPasswordHint; String? _currentPasswordEmail; @@ -71,17 +64,14 @@ class ApiService { final Map> _messageCache = {}; - final Map _contactCache = {}; DateTime? _lastContactsUpdate; static const Duration _contactCacheExpiry = Duration( minutes: 5, ); // Кэш на 5 минут - bool _isLoadingBlockedContacts = false; - bool _isSessionReady = false; final _messageController = StreamController>.broadcast(); @@ -93,11 +83,9 @@ class ApiService { final _connectionLogController = StreamController.broadcast(); Stream get connectionLog => _connectionLogController.stream; - final List _connectionLogCache = []; List get connectionLogCache => _connectionLogCache; - void _log(String message) { print(message); // Оставляем для дебага в консоли _connectionLogCache.add(message); @@ -121,13 +109,10 @@ class ApiService { bool get isActuallyConnected { try { - if (_channel == null || !_isSessionOnline) { return false; } - - return true; } catch (e) { print("🔴 Ошибка при проверке состояния канала: $e"); @@ -135,14 +120,12 @@ class ApiService { } } - Completer>? _inflightChatsCompleter; Map? _lastChatsPayload; DateTime? _lastChatsAt; final Duration _chatsCacheTtl = const Duration(seconds: 5); bool _chatsFetchedInThisSession = false; - Map? get lastChatsPayload => _lastChatsPayload; Future _connectWithFallback() async { @@ -171,14 +154,12 @@ class ApiService { _connectionLogController.add(errorMessage); _currentUrlIndex++; - if (_currentUrlIndex < _wsUrls.length) { await Future.delayed(const Duration(milliseconds: 500)); } } } - _log('❌ Все серверы недоступны'); _connectionStatusController.add('Все серверы недоступны'); throw Exception('Не удалось подключиться ни к одному серверу'); @@ -207,11 +188,9 @@ class ApiService { 'Sec-WebSocket-Extensions': 'permessage-deflate', }; - final proxySettings = await ProxyService.instance.loadProxySettings(); if (proxySettings.isEnabled && proxySettings.host.isNotEmpty) { - print( 'Используем HTTP/HTTPS прокси ${proxySettings.host}:${proxySettings.port}', ); @@ -223,7 +202,6 @@ class ApiService { customClient: customHttpClient, ); } else { - print('Подключение без прокси'); _channel = IOWebSocketChannel.connect(uri, headers: headers); } @@ -241,7 +219,6 @@ class ApiService { bool _isReconnecting = false; String generateRandomDeviceId() { - return const Uuid().v4(); } @@ -256,11 +233,9 @@ class ApiService { final String? idFromSpoofing = spoofedData['device_id'] as String?; if (idFromSpoofing != null && idFromSpoofing.isNotEmpty) { - finalDeviceId = idFromSpoofing; print('Используется deviceId из сессии: $finalDeviceId'); } else { - finalDeviceId = generateRandomDeviceId(); print('device_id не найден в кэше, сгенерирован новый: $finalDeviceId'); } @@ -301,13 +276,10 @@ class ApiService { _isSessionOnline = false; _isSessionReady = false; - authToken = null; - clearAllCaches(); - _messageController.add({ 'type': 'session_terminated', 'message': 'Твоя сессия больше не активна, войди снова', @@ -319,20 +291,16 @@ class ApiService { _isSessionOnline = false; _isSessionReady = false; - authToken = null; final prefs = await SharedPreferences.getInstance(); await prefs.remove('authToken'); - clearAllCaches(); - _channel?.sink.close(); _channel = null; _pingTimer?.cancel(); - _messageController.add({ 'type': 'invalid_token', 'message': 'Токен недействителен, требуется повторная авторизация', @@ -346,7 +314,6 @@ class ApiService { _lastChatsAt = null; _chatsFetchedInThisSession = false; - final prefs = await SharedPreferences.getInstance(); await prefs.remove('authToken'); @@ -354,7 +321,6 @@ class ApiService { _connectionStatusController.add("disconnected"); } - Future _sendHandshake() async { if (_handshakeSent) { print('Handshake уже отправлен, пропускаем...'); @@ -381,9 +347,7 @@ class ApiService { print('Handshake отправлен, ожидаем ответ...'); } - Future requestOtp(String phoneNumber) async { - if (_channel == null) { print('WebSocket не подключен, подключаемся...'); try { @@ -404,12 +368,10 @@ class ApiService { _sendMessage(17, payload); } - void requestSessions() { _sendMessage(96, {}); } - void terminateAllSessions() { _sendMessage(97, {}); } @@ -442,7 +404,6 @@ class ApiService { await subscribeToChat(targetChatId, true); } - Future clearChatHistory(int chatId, {bool forAll = false}) async { await waitUntilOnline(); final payload = { @@ -496,9 +457,7 @@ class ApiService { } } - void markMessageAsRead(int chatId, String messageId) { - waitUntilOnline().then((_) { final payload = { "type": "READ_MESSAGE", @@ -513,9 +472,7 @@ class ApiService { }); } - void getBlockedContacts() async { - if (_isLoadingBlockedContacts) { print( 'ApiService: запрос заблокированных контактов уже выполняется, пропускаем', @@ -525,20 +482,13 @@ class ApiService { _isLoadingBlockedContacts = true; print('ApiService: запрашиваем заблокированные контакты'); - _sendMessage(36, { - 'status': 'BLOCKED', - 'count': 100, - 'from': 0, - - }); - + _sendMessage(36, {'status': 'BLOCKED', 'count': 100, 'from': 0}); Future.delayed(const Duration(seconds: 2), () { _isLoadingBlockedContacts = false; }); } - void notifyContactUpdate(Contact contact) { print( 'ApiService отправляет обновление контакта: ${contact.name} (ID: ${contact.id}), isBlocked: ${contact.isBlocked}, isBlockedByMe: ${contact.isBlockedByMe}', @@ -546,7 +496,6 @@ class ApiService { _contactUpdatesController.add(contact); } - DateTime? getLastSeen(int userId) { final userPresence = _presenceData[userId.toString()]; if (userPresence != null && userPresence['seen'] != null) { @@ -557,13 +506,11 @@ class ApiService { return null; } - void updatePresenceData(Map presenceData) { _presenceData.addAll(presenceData); print('ApiService обновил presence данные: $_presenceData'); } - void sendReaction(int chatId, String messageId, String emoji) { final payload = { "chatId": chatId, @@ -574,28 +521,18 @@ class ApiService { print('Отправляем реакцию: $emoji на сообщение $messageId в чате $chatId'); } - void removeReaction(int chatId, String messageId) { final payload = {"chatId": chatId, "messageId": messageId}; _sendMessage(179, payload); print('Удаляем реакцию с сообщения $messageId в чате $chatId'); } - void createGroup(String name, List participantIds) { final payload = {"name": name, "participantIds": participantIds}; _sendMessage(48, payload); print('Создаем группу: $name с участниками: $participantIds'); } - - - - - - - - void updateGroup(int chatId, {String? name, List? participantIds}) { final payload = { "chatId": chatId, @@ -606,7 +543,6 @@ class ApiService { print('Обновляем группу $chatId: $payload'); } - void createGroupWithMessage(String name, List participantIds) { final cid = DateTime.now().millisecondsSinceEpoch; final payload = { @@ -628,14 +564,12 @@ class ApiService { print('Создаем группу: $name с участниками: $participantIds'); } - void renameGroup(int chatId, String newName) { final payload = {"chatId": chatId, "theme": newName}; _sendMessage(55, payload); print('Переименовываем группу $chatId в: $newName'); } - void addGroupMember( int chatId, List userIds, { @@ -651,7 +585,6 @@ class ApiService { print('Добавляем участников $userIds в группу $chatId'); } - void removeGroupMember( int chatId, List userIds, { @@ -667,16 +600,12 @@ class ApiService { print('Удаляем участников $userIds из группы $chatId'); } - void leaveGroup(int chatId) { final payload = {"chatId": chatId}; _sendMessage(58, payload); print('Выходим из группы $chatId'); } - - - void getGroupMembers(int chatId, {int marker = 0, int count = 50}) { final payload = { "type": "MEMBER", @@ -690,7 +619,6 @@ class ApiService { ); } - Future getChatIdByUserId(int userId) async { await waitUntilOnline(); @@ -740,7 +668,6 @@ class ApiService { } } - Future> getChatsOnly({bool force = false}) async { if (authToken == null) { final prefs = await SharedPreferences.getInstance(); @@ -748,7 +675,6 @@ class ApiService { } if (authToken == null) throw Exception("Auth token not found"); - if (!force && _lastChatsPayload != null && _lastChatsAt != null) { if (DateTime.now().difference(_lastChatsAt!) < _chatsCacheTtl) { return _lastChatsPayload!; @@ -798,7 +724,6 @@ class ApiService { }; _lastChatsPayload = result; - final contacts = contactListJson .map((json) => Contact.fromJson(json)) .toList(); @@ -811,9 +736,7 @@ class ApiService { } } - Future verifyCode(String token, String code) async { - _currentPasswordTrackId = null; _currentPasswordHint = null; _currentPasswordEmail = null; @@ -830,7 +753,6 @@ class ApiService { } } - final payload = { 'token': token, 'verifyCode': code, @@ -841,7 +763,6 @@ class ApiService { print('Код верификации отправлен с payload: $payload'); } - Future sendPassword(String trackId, String password) async { await waitUntilOnline(); @@ -851,7 +772,6 @@ class ApiService { print('Пароль отправлен с payload: $payload'); } - Map getPasswordAuthData() { return { 'trackId': _currentPasswordTrackId, @@ -860,14 +780,12 @@ class ApiService { }; } - void clearPasswordAuthData() { _currentPasswordTrackId = null; _currentPasswordHint = null; _currentPasswordEmail = null; } - Future setAccountPassword(String password, String hint) async { await waitUntilOnline(); @@ -877,7 +795,6 @@ class ApiService { print('Запрос на установку пароля отправлен с payload: $payload'); } - Future> joinGroupByLink(String link) async { await waitUntilOnline(); @@ -917,7 +834,6 @@ class ApiService { } } - Future searchContactByPhone(String phone) async { await waitUntilOnline(); @@ -927,18 +843,15 @@ class ApiService { print('Запрос на поиск контакта отправлен с payload: $payload'); } - Future searchChannels(String query) async { await waitUntilOnline(); - final payload = {'contactIds': []}; _sendMessage(32, payload); print('Запрос на поиск каналов отправлен с payload: $payload'); } - Future enterChannel(String link) async { await waitUntilOnline(); @@ -948,7 +861,6 @@ class ApiService { print('Запрос на вход в канал отправлен с payload: $payload'); } - Future subscribeToChannel(String link) async { await waitUntilOnline(); @@ -966,25 +878,21 @@ class ApiService { throw Exception("Auth token not found - please re-authenticate"); } - if (!force && _lastChatsPayload != null && _lastChatsAt != null) { if (DateTime.now().difference(_lastChatsAt!) < _chatsCacheTtl) { return _lastChatsPayload!; } } - if (_chatsFetchedInThisSession && _lastChatsPayload != null && !force) { return _lastChatsPayload!; } - if (_inflightChatsCompleter != null) { return _inflightChatsCompleter!.future; } _inflightChatsCompleter = Completer>(); - if (_isSessionOnline && _isSessionReady && _lastChatsPayload != null && @@ -997,7 +905,6 @@ class ApiService { try { Map chatResponse; - final int opcode; final Map payload; @@ -1005,13 +912,11 @@ class ApiService { final deviceId = prefs.getString('spoof_deviceid') ?? generateRandomDeviceId(); - if (prefs.getString('spoof_deviceid') == null) { await prefs.setString('spoof_deviceid', deviceId); } if (!_chatsFetchedInThisSession) { - opcode = 19; payload = { "chatsCount": 100, @@ -1021,16 +926,12 @@ class ApiService { "interactive": true, "presenceSync": 0, "token": authToken, - - }; - if (userId != null) { payload["userId"] = userId; } } else { - return await getChatsOnly(force: force); } @@ -1054,10 +955,8 @@ class ApiService { _sessionId = DateTime.now().millisecondsSinceEpoch; _lastActionTime = _sessionId; - sendNavEvent('COLD_START'); - _sendInitialSetupRequests(); } else { print( @@ -1065,12 +964,10 @@ class ApiService { ); } - if (_onlineCompleter != null && !_onlineCompleter!.isCompleted) { _onlineCompleter!.complete(); } - _startPinging(); _processMessageQueue(); } @@ -1112,7 +1009,6 @@ class ApiService { final List contactListJson = contactResponse['payload']?['contacts'] ?? []; - if (presence != null) { updatePresenceData(presence); } @@ -1126,7 +1022,6 @@ class ApiService { }; _lastChatsPayload = result; - final contacts = contactListJson .map((json) => Contact.fromJson(json)) .toList(); @@ -1144,7 +1039,6 @@ class ApiService { } } - Future> getMessageHistory( int chatId, { bool force = false, @@ -1158,7 +1052,6 @@ class ApiService { final payload = { "chatId": chatId, - "from": DateTime.now() .add(const Duration(days: 1)) .millisecondsSinceEpoch, @@ -1173,12 +1066,10 @@ class ApiService { .firstWhere((msg) => msg['seq'] == seq) .timeout(const Duration(seconds: 15)); - if (response['cmd'] == 3) { final error = response['payload']; print('Ошибка получения истории сообщений: $error'); - if (error['error'] == 'proto.state') { print( 'Ошибка состояния сессии при получении истории, переподключаемся...', @@ -1206,7 +1097,6 @@ class ApiService { } } - Future?> loadOldMessages( int chatId, String fromMessageId, @@ -1230,7 +1120,6 @@ class ApiService { .firstWhere((msg) => msg['seq'] == seq) .timeout(const Duration(seconds: 15)); - if (response['cmd'] == 3) { final error = response['payload']; print('Ошибка получения старых сообщений: $error'); @@ -1248,7 +1137,6 @@ class ApiService { _isAppInForeground = isForeground; } - void sendNavEvent(String event, {int? screenTo, int? screenFrom}) { if (_userId == null) return; @@ -1292,6 +1180,37 @@ class ApiService { }); } + void createFolder( + String title, { + List? include, + List? filters, + }) { + final folderId = const Uuid().v4(); + final payload = { + "id": folderId, + "title": title, + "include": include ?? [], + "filters": filters ?? [], + }; + _sendMessage(274, payload); + print('Создаем папку: $title (ID: $folderId)'); + } + + void updateFolder( + String folderId, { + String? title, + List? include, + List? filters, + }) { + final payload = { + "id": folderId, + if (title != null) "title": title, + if (include != null) "include": include, + if (filters != null) "filters": filters, + }; + _sendMessage(274, payload); + print('Обновляем папку: $folderId'); + } Future _sendInitialSetupRequests() async { print("Запускаем отправку единичных запросов при старте..."); @@ -1327,7 +1246,6 @@ class ApiService { print("Кэш чатов очищен."); } - Contact? getCachedContact(int contactId) { if (_contactCache.containsKey(contactId)) { final contact = _contactCache[contactId]!; @@ -1337,12 +1255,9 @@ class ApiService { return null; } - Future> getNetworkStatistics() async { - final prefs = await SharedPreferences.getInstance(); - final totalTraffic = prefs.getDouble('network_total_traffic') ?? (150.0 * 1024 * 1024); // 150 MB по умолчанию @@ -1353,12 +1268,10 @@ class ApiService { final syncTraffic = prefs.getDouble('network_sync_traffic') ?? (totalTraffic * 0.1); - final currentSpeed = _isSessionOnline ? 512.0 * 1024 : 0.0; // 512 KB/s если онлайн - final ping = 25; return { @@ -1378,14 +1291,12 @@ class ApiService { }; } - bool isContactCacheValid() { if (_lastContactsUpdate == null) return false; return DateTime.now().difference(_lastContactsUpdate!) < _contactCacheExpiry; } - void updateContactCache(List contacts) { _contactCache.clear(); for (final contact in contacts) { @@ -1395,20 +1306,17 @@ class ApiService { print('Кэш контактов обновлен: ${contacts.length} контактов'); } - void updateCachedContact(Contact contact) { _contactCache[contact.id] = contact; print('Контакт ${contact.id} обновлен в кэше: ${contact.name}'); } - void clearContactCache() { _contactCache.clear(); _lastContactsUpdate = null; print("Кэш контактов очищен."); } - void clearAllCaches() { clearContactCache(); clearChatsCache(); @@ -1417,32 +1325,25 @@ class ApiService { print("Все кэши очищены из-за ошибки подключения."); } - Future clearAllData() async { try { - clearAllCaches(); - authToken = null; - final prefs = await SharedPreferences.getInstance(); await prefs.clear(); - _pingTimer?.cancel(); await _channel?.sink.close(); _channel = null; - _isSessionOnline = false; _isSessionReady = false; _chatsFetchedInThisSession = false; _reconnectAttempts = 0; _currentUrlIndex = 0; - _messageQueue.clear(); _presenceData.clear(); @@ -1453,7 +1354,6 @@ class ApiService { } } - void sendMessage( int chatId, String text, { @@ -1474,7 +1374,6 @@ class ApiService { "notify": true, }; - clearChatsCache(); if (_isSessionOnline) { @@ -1494,7 +1393,6 @@ class ApiService { _messageQueue.clear(); } - Future editMessage(int chatId, String messageId, String newText) async { final payload = { "chatId": chatId, @@ -1504,13 +1402,10 @@ class ApiService { "attachments": [], }; - clearChatsCache(); - await waitUntilOnline(); - if (!_isSessionOnline) { print('Сессия не онлайн, пытаемся переподключиться...'); await reconnect(); @@ -1524,12 +1419,10 @@ class ApiService { .firstWhere((msg) => msg['seq'] == seq) .timeout(const Duration(seconds: 10)); - if (response['cmd'] == 3) { final error = response['payload']; print('Ошибка редактирования сообщения: $error'); - if (error['error'] == 'proto.state') { print('Ошибка состояния сессии, переподключаемся...'); await reconnect(); @@ -1537,7 +1430,6 @@ class ApiService { return false; // Попробуем еще раз } - if (error['error'] == 'error.edit.invalid.message') { print( 'Сообщение не может быть отредактировано: ${error['localizedMessage']}', @@ -1557,7 +1449,6 @@ class ApiService { } } - for (int attempt = 0; attempt < 3; attempt++) { print( 'Попытка редактирования сообщения $messageId (попытка ${attempt + 1}/3)', @@ -1579,7 +1470,6 @@ class ApiService { print('Не удалось отредактировать сообщение $messageId после 3 попыток'); } - Future deleteMessage( int chatId, String messageId, { @@ -1591,13 +1481,10 @@ class ApiService { "forMe": forMe, }; - clearChatsCache(); - await waitUntilOnline(); - if (!_isSessionOnline) { print('Сессия не онлайн, пытаемся переподключиться...'); await reconnect(); @@ -1611,12 +1498,10 @@ class ApiService { .firstWhere((msg) => msg['seq'] == seq) .timeout(const Duration(seconds: 10)); - if (response['cmd'] == 3) { final error = response['payload']; print('Ошибка удаления сообщения: $error'); - if (error['error'] == 'proto.state') { print('Ошибка состояния сессии, переподключаемся...'); await reconnect(); @@ -1633,7 +1518,6 @@ class ApiService { } } - for (int attempt = 0; attempt < 3; attempt++) { print('Попытка удаления сообщения $messageId (попытка ${attempt + 1}/3)'); bool ok = await sendOnce(); @@ -1653,7 +1537,6 @@ class ApiService { print('Не удалось удалить сообщение $messageId после 3 попыток'); } - void sendTyping(int chatId, {String type = "TEXT"}) { final payload = {"chatId": chatId, "type": type}; if (_isSessionOnline) { @@ -1661,7 +1544,6 @@ class ApiService { } } - void updateProfileText( String firstName, String lastName, @@ -1675,22 +1557,18 @@ class ApiService { _sendMessage(16, payload); } - Future updateProfilePhoto(String firstName, String lastName) async { try { - final picker = ImagePicker(); final XFile? image = await picker.pickImage(source: ImageSource.gallery); if (image == null) return; - print("Запрашиваем URL для загрузки фото..."); final int seq = _sendMessage(80, {"count": 1}); final response = await messages.firstWhere((msg) => msg['seq'] == seq); final String uploadUrl = response['payload']['url']; print("URL получен: $uploadUrl"); - print("Загружаем фото на сервер..."); var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); request.files.add(await http.MultipartFile.fromPath('file', image.path)); @@ -1705,7 +1583,6 @@ class ApiService { final String photoToken = uploadResult['photos'].values.first['token']; print("Фото загружено, получен токен: $photoToken"); - print("Привязываем фото к профилю..."); final payload = { "firstName": firstName, @@ -1720,7 +1597,6 @@ class ApiService { } } - Future sendPhotoMessage( int chatId, { String? localPath, @@ -1733,7 +1609,6 @@ class ApiService { if (localPath != null) { image = XFile(localPath); } else { - final picker = ImagePicker(); image = await picker.pickImage(source: ImageSource.gallery); if (image == null) return; @@ -1745,7 +1620,6 @@ class ApiService { final resp80 = await messages.firstWhere((m) => m['seq'] == seq80); final String uploadUrl = resp80['payload']['url']; - var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); request.files.add(await http.MultipartFile.fromPath('file', image.path)); var streamed = await request.send(); @@ -1760,7 +1634,6 @@ class ApiService { if (photos.isEmpty) throw Exception('Не получен токен фото'); final String photoToken = (photos.values.first as Map)['token']; - final int cid = cidOverride ?? DateTime.now().millisecondsSinceEpoch; final payload = { "chatId": chatId, @@ -1806,7 +1679,6 @@ class ApiService { } } - Future sendPhotoMessages( int chatId, { required List localPaths, @@ -1817,7 +1689,6 @@ class ApiService { try { await waitUntilOnline(); - final int cid = DateTime.now().millisecondsSinceEpoch; _emitLocal({ 'ver': 11, @@ -1841,7 +1712,6 @@ class ApiService { }, }); - final List> photoTokens = []; for (final path in localPaths) { final int seq80 = _sendMessage(80, {"count": 1}); @@ -1885,14 +1755,12 @@ class ApiService { } } - Future sendFileMessage( int chatId, { String? caption, int? senderId, // my user id to mark local echo as mine }) async { try { - FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.any, ); @@ -1908,7 +1776,6 @@ class ApiService { await waitUntilOnline(); - final int seq87 = _sendMessage(87, {"count": 1}); final resp87 = await messages.firstWhere((m) => m['seq'] == seq87); @@ -1924,7 +1791,6 @@ class ApiService { print('Получен fileId: $fileId и URL: $uploadUrl'); - var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); request.files.add(await http.MultipartFile.fromPath('file', filePath)); var streamed = await request.send(); @@ -1937,8 +1803,6 @@ class ApiService { print('Файл успешно загружен на сервер.'); - - final int cid = DateTime.now().millisecondsSinceEpoch; final payload = { "chatId": chatId, @@ -1955,7 +1819,6 @@ class ApiService { clearChatsCache(); - _emitLocal({ 'ver': 11, 'cmd': 1, @@ -2021,7 +1884,6 @@ class ApiService { } Future hasToken() async { - if (authToken == null) { final prefs = await SharedPreferences.getInstance(); authToken = prefs.getString('authToken'); @@ -2039,7 +1901,6 @@ class ApiService { } Future> fetchContactsByIds(List contactIds) async { - if (contactIds.isEmpty) { return []; } @@ -2048,12 +1909,10 @@ class ApiService { try { final int contactSeq = _sendMessage(32, {"contactIds": contactIds}); - final contactResponse = await messages .firstWhere((msg) => msg['seq'] == contactSeq) .timeout(const Duration(seconds: 10)); - if (contactResponse['cmd'] == 3) { print( "Ошибка при получении контактов по ID: ${contactResponse['payload']}", @@ -2067,7 +1926,6 @@ class ApiService { .map((json) => Contact.fromJson(json)) .toList(); - for (final contact in contacts) { _contactCache[contact.id] = contact; } @@ -2096,7 +1954,6 @@ class ApiService { } Future connect() async { - if (_channel != null && _isSessionOnline) { print("WebSocket уже подключен, пропускаем подключение"); return; @@ -2104,11 +1961,9 @@ class ApiService { print("Запускаем подключение к WebSocket..."); - _isSessionOnline = false; _isSessionReady = false; - _connectionStatusController.add("connecting"); await _connectWithFallback(); } @@ -2122,9 +1977,6 @@ class ApiService { await _connectWithFallback(); } - - - void sendFullJsonRequest(String jsonString) { if (_channel == null) { throw Exception('WebSocket is not connected. Connect first.'); @@ -2133,9 +1985,6 @@ class ApiService { _channel!.sink.add(jsonString); } - - - int sendRawRequest(int opcode, Map payload) { if (_channel == null) { print('WebSocket не подключен!'); @@ -2146,23 +1995,17 @@ class ApiService { return _sendMessage(opcode, payload); } - - int sendAndTrackFullJsonRequest(String jsonString) { if (_channel == null) { throw Exception('WebSocket is not connected. Connect first.'); } - final message = jsonDecode(jsonString) as Map; - final int currentSeq = _seq++; - message['seq'] = currentSeq; - final encodedMessage = jsonEncode(message); _log('➡️ SEND (custom): $encodedMessage'); @@ -2170,7 +2013,6 @@ class ApiService { _channel!.sink.add(encodedMessage); - return currentSeq; } @@ -2188,10 +2030,8 @@ class ApiService { }; final encodedMessage = jsonEncode(message); if (opcode == 1) { - _log('➡️ SEND (ping) seq: $_seq'); } else if (opcode == 18 || opcode == 19) { - Map loggablePayload = Map.from(payload); if (loggablePayload.containsKey('token')) { String token = loggablePayload['token'] as String; @@ -2216,16 +2056,13 @@ class ApiService { (message) { if (message == null) return; if (message is String && message.trim().isEmpty) { - return; } - String loggableMessage = message; try { final decoded = jsonDecode(message) as Map; if (decoded['opcode'] == 2) { - loggableMessage = '⬅️ RECV (pong) seq: ${decoded['seq']}'; } else { Map loggableDecoded = Map.from(decoded); @@ -2255,13 +2092,11 @@ class ApiService { } _log(loggableMessage); - try { final decodedMessage = message is String ? jsonDecode(message) : message; - if (decodedMessage is Map && decodedMessage['opcode'] == 97 && decodedMessage['cmd'] == 1 && @@ -2287,7 +2122,6 @@ class ApiService { _processMessageQueue(); } - if (decodedMessage is Map && decodedMessage['cmd'] == 3) { final error = decodedMessage['payload']; print('Ошибка сервера: $error'); @@ -2302,7 +2136,6 @@ class ApiService { _errorController.add('FAIL_WRONG_PASSWORD'); } - if (error != null && error['error'] == 'password.invalid') { _errorController.add('Неверный пароль'); } @@ -2335,7 +2168,6 @@ class ApiService { } } - if (decodedMessage is Map && decodedMessage['opcode'] == 18 && decodedMessage['cmd'] == 1 && @@ -2351,7 +2183,6 @@ class ApiService { 'Получен запрос на ввод пароля: trackId=${challenge['trackId']}, hint=${challenge['hint']}, email=${challenge['email']}', ); - _messageController.add({ 'type': 'password_required', 'trackId': _currentPasswordTrackId, @@ -2362,168 +2193,144 @@ class ApiService { } } - if (decodedMessage is Map && decodedMessage['opcode'] == 22 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Настройки приватности успешно обновлены: $payload'); - _messageController.add({ 'type': 'privacy_settings_updated', 'settings': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 116 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Пароль успешно установлен: $payload'); - _messageController.add({ 'type': 'password_set_success', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 57 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Успешно присоединились к группе: $payload'); - _messageController.add({ 'type': 'group_join_success', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 46 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Контакт найден: $payload'); - _messageController.add({ 'type': 'contact_found', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 46 && decodedMessage['cmd'] == 3) { final payload = decodedMessage['payload']; print('Контакт не найден: $payload'); - _messageController.add({ 'type': 'contact_not_found', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 32 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Каналы найдены: $payload'); - _messageController.add({ 'type': 'channels_found', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 32 && decodedMessage['cmd'] == 3) { final payload = decodedMessage['payload']; print('Каналы не найдены: $payload'); - _messageController.add({ 'type': 'channels_not_found', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 89 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Вход в канал успешен: $payload'); - _messageController.add({ 'type': 'channel_entered', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 89 && decodedMessage['cmd'] == 3) { final payload = decodedMessage['payload']; print('Ошибка входа в канал: $payload'); - _messageController.add({ 'type': 'channel_error', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 57 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Подписка на канал успешна: $payload'); - _messageController.add({ 'type': 'channel_subscribed', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 57 && decodedMessage['cmd'] == 3) { final payload = decodedMessage['payload']; print('Ошибка подписки на канал: $payload'); - _messageController.add({ 'type': 'channel_error', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 59 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Получены участники группы: $payload'); - _messageController.add({ 'type': 'group_members', 'payload': payload, @@ -2578,13 +2385,10 @@ class ApiService { _onlineCompleter = Completer(); _chatsFetchedInThisSession = false; - clearAllCaches(); - _currentUrlIndex = 0; - _reconnectDelaySeconds = (_reconnectDelaySeconds * 2).clamp(1, 30); final jitter = (DateTime.now().millisecondsSinceEpoch % 1000) / 1000.0; final delay = Duration(seconds: _reconnectDelaySeconds + jitter.round()); @@ -2611,32 +2415,27 @@ class ApiService { print('Запрашиваем URL для videoId: $videoId (seq: $seq)'); try { - final response = await messages .firstWhere((msg) => msg['seq'] == seq && msg['opcode'] == 83) .timeout(const Duration(seconds: 15)); - if (response['cmd'] == 3) { throw Exception( 'Ошибка получения URL видео: ${response['payload']?['message']}', ); } - final videoPayload = response['payload'] as Map?; if (videoPayload == null) { throw Exception('Получен пустой payload для видео'); } - String? videoUrl = videoPayload['MP4_720'] as String? ?? videoPayload['MP4_480'] as String? ?? videoPayload['MP4_1080'] as String? ?? videoPayload['MP4_360'] as String?; - if (videoUrl == null) { final mp4Key = videoPayload.keys.firstWhere( (k) => k.startsWith('MP4_'), @@ -2673,12 +2472,10 @@ class ApiService { _onlineCompleter = Completer(); _chatsFetchedInThisSession = false; - _channel?.sink.close(status.goingAway); _channel = null; _streamSubscription = null; - _connectionStatusController.add("disconnected"); } @@ -2687,11 +2484,9 @@ class ApiService { return data?.text; } - void forceReconnect() { print("Принудительное переподключение..."); - _pingTimer?.cancel(); _reconnectTimer?.cancel(); if (_channel != null) { @@ -2700,7 +2495,6 @@ class ApiService { _channel = null; } - _isReconnecting = false; _reconnectAttempts = 0; _reconnectDelaySeconds = 2; @@ -2710,35 +2504,28 @@ class ApiService { _currentUrlIndex = 0; _onlineCompleter = Completer(); // Re-create completer - clearAllCaches(); _messageQueue.clear(); _presenceData.clear(); - _connectionStatusController.add("connecting"); _log("Запускаем новую сессию подключения..."); - _connectWithFallback(); } - Future performFullReconnection() async { print("🔄 Начинаем полное переподключение..."); try { - _pingTimer?.cancel(); _reconnectTimer?.cancel(); _streamSubscription?.cancel(); - if (_channel != null) { _channel!.sink.close(); _channel = null; } - _isReconnecting = false; _reconnectAttempts = 0; _reconnectDelaySeconds = 2; @@ -2750,7 +2537,6 @@ class ApiService { _onlineCompleter = Completer(); _seq = 0; - _lastChatsPayload = null; _lastChatsAt = null; @@ -2760,15 +2546,12 @@ class ApiService { _connectionStatusController.add("disconnected"); - await connect(); print("✅ Полное переподключение завершено"); - await Future.delayed(const Duration(milliseconds: 1500)); - if (!_reconnectionCompleteController.isClosed) { print("📢 Отправляем уведомление о завершении переподключения"); _reconnectionCompleteController.add(null); @@ -2779,7 +2562,6 @@ class ApiService { } } - Future updatePrivacySettings({ String? hidden, String? searchByPhone, @@ -2807,7 +2589,6 @@ class ApiService { print('Обновляем настройки приватности: $settings'); - if (hidden != null) { await _updateSinglePrivacySetting({'HIDDEN': hidden == 'true'}); } @@ -2821,7 +2602,6 @@ class ApiService { await _updateSinglePrivacySetting({'CHATS_INVITE': chatsInvite}); } - if (chatsPushNotification != null) { await _updateSinglePrivacySetting({ 'PUSH_NEW_CONTACTS': chatsPushNotification, @@ -2841,7 +2621,6 @@ class ApiService { } } - Future _updateSinglePrivacySetting(Map setting) async { await waitUntilOnline(); diff --git a/lib/chat_screen.dart b/lib/chat_screen.dart index e424296..a53aa39 100644 --- a/lib/chat_screen.dart +++ b/lib/chat_screen.dart @@ -21,7 +21,6 @@ import 'package:video_player/video_player.dart'; bool _debugShowExactDate = false; - void toggleDebugExactDate() { _debugShowExactDate = !_debugShowExactDate; print('Debug режим точной даты: $_debugShowExactDate'); @@ -88,13 +87,12 @@ class _ChatScreenState extends State { ItemPositionsListener.create(); final ValueNotifier _showScrollToBottomNotifier = ValueNotifier(false); - late Contact _currentContact; - Message? _replyingToMessage; final Map _contactDetailsCache = {}; + final Set _loadingContactIds = {}; final Map _lastReadMessageIdByParticipant = {}; @@ -143,6 +141,30 @@ class _ChatScreenState extends State { } } + Future _loadContactIfNeeded(int contactId) async { + if (_contactDetailsCache.containsKey(contactId) || + _loadingContactIds.contains(contactId)) { + return; + } + + _loadingContactIds.add(contactId); + + try { + final contacts = await ApiService.instance.fetchContactsByIds([ + contactId, + ]); + if (contacts.isNotEmpty && mounted) { + final contact = contacts.first; + _contactDetailsCache[contact.id] = contact; + setState(() {}); + } + } catch (e) { + print('Ошибка загрузки контакта $contactId: $e'); + } finally { + _loadingContactIds.remove(contactId); + } + } + @override void initState() { super.initState(); @@ -375,7 +397,6 @@ class _ChatScreenState extends State { if (!mounted) return; print("✅ Получено ${allMessages.length} сообщений с сервера."); - final Set senderIds = {}; for (final message in allMessages) { senderIds.add(message.senderId); @@ -389,7 +410,6 @@ class _ChatScreenState extends State { } senderIds.remove(0); // Удаляем системный ID, если он есть - final idsToFetch = senderIds .where((id) => !_contactDetailsCache.containsKey(id)) .toList(); @@ -475,8 +495,6 @@ class _ChatScreenState extends State { _buildChatItems(); _isLoadingMore = false; setState(() {}); - - } bool _isSameDay(DateTime date1, DateTime date2) { @@ -532,12 +550,10 @@ class _ChatScreenState extends State { print('DEBUG GROUPING: isGrouped=$isGrouped'); } - final isFirstInGroup = previousMessage == null || !_isMessageGrouped(currentMessage, previousMessage); - final isLastInGroup = i == source.length - 1 || !_isMessageGrouped(source[i + 1], currentMessage); @@ -1155,7 +1171,6 @@ class _ChatScreenState extends State { await Future.delayed(const Duration(milliseconds: 500)); if (mounted) { - Navigator.of(context).pop(); widget.onChatUpdated?.call(); @@ -1213,11 +1228,9 @@ class _ChatScreenState extends State { onPressed: () { Navigator.of(context).pop(); // Закрываем диалог подтверждения try { - ApiService.instance.leaveGroup(widget.chatId); if (mounted) { - Navigator.of(context).pop(); widget.onChatUpdated?.call(); @@ -1388,30 +1401,43 @@ class _ChatScreenState extends State { if (isMe) { final messageId = item.message.id; if (messageId.startsWith('local_')) { - - readStatus = MessageReadStatus.sending; } else { - - readStatus = MessageReadStatus.sent; - - - - - - - - - - } } + String? forwardedFrom; + String? forwardedFromAvatarUrl; if (message.isForwarded) { - final originalSenderId = - message.link?['message']?['sender'] as int?; - if (originalSenderId != null) {} + final link = message.link; + if (link is Map) { + final chatName = link['chatName'] as String?; + final chatIconUrl = link['chatIconUrl'] as String?; + + if (chatName != null) { + forwardedFrom = chatName; + forwardedFromAvatarUrl = chatIconUrl; + } else { + final forwardedMessage = + link['message'] as Map?; + final originalSenderId = + forwardedMessage?['sender'] as int?; + if (originalSenderId != null) { + final originalSenderContact = + _contactDetailsCache[originalSenderId]; + if (originalSenderContact == null) { + _loadContactIfNeeded(originalSenderId); + forwardedFrom = 'Участник $originalSenderId'; + forwardedFromAvatarUrl = null; + } else { + forwardedFrom = originalSenderContact.name; + forwardedFromAvatarUrl = + originalSenderContact.photoBaseUrl; + } + } + } + } } String? senderName; if (widget.isGroupChat && !isMe) { @@ -1500,6 +1526,8 @@ class _ChatScreenState extends State { isGroupChat: widget.isGroupChat, isChannel: widget.isChannel, senderName: senderName, + forwardedFrom: forwardedFrom, + forwardedFromAvatarUrl: forwardedFromAvatarUrl, contactDetailsCache: _contactDetailsCache, onReplyTap: _scrollToMessage, useAutoReplyColor: context @@ -1659,7 +1687,6 @@ class _ChatScreenState extends State { leading: widget.isDesktopMode ? null // В десктопном режиме нет кнопки "Назад" : IconButton( - icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), @@ -1908,7 +1935,6 @@ class _ChatScreenState extends State { ), ) else - _ContactPresenceSubtitle( chatId: widget.chatId, userId: widget.contact.id, @@ -1998,7 +2024,6 @@ class _ChatScreenState extends State { ], ); case ChatWallpaperType.video: - if (Platform.isWindows) { return Container( color: Theme.of(context).colorScheme.surface, @@ -2195,16 +2220,13 @@ class _ChatScreenState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: Focus( focusNode: _textFocusNode, // 2. focusNode теперь здесь onKeyEvent: (node, event) { - if (event is KeyDownEvent) { if (event.logicalKey == LogicalKeyboardKey.enter) { - final bool isShiftPressed = HardwareKeyboard.instance.logicalKeysPressed .contains( @@ -2216,7 +2238,6 @@ class _ChatScreenState extends State { ); if (!isShiftPressed) { - _sendMessage(); return KeyEventResult.handled; } @@ -3292,7 +3313,6 @@ class GroupProfileDraggableDialog extends StatelessWidget { ), child: Column( children: [ - Container( margin: const EdgeInsets.only(top: 8), width: 40, @@ -3303,7 +3323,6 @@ class GroupProfileDraggableDialog extends StatelessWidget { ), ), - Padding( padding: const EdgeInsets.all(20), child: Hero( @@ -3325,7 +3344,6 @@ class GroupProfileDraggableDialog extends StatelessWidget { ), ), - Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( @@ -3343,8 +3361,6 @@ class GroupProfileDraggableDialog extends StatelessWidget { IconButton( icon: Icon(Icons.settings, color: colors.primary), onPressed: () async { - - final myId = 0; // This should be passed or retrieved Navigator.of(context).pop(); @@ -3367,13 +3383,11 @@ class GroupProfileDraggableDialog extends StatelessWidget { const SizedBox(height: 20), - Expanded( child: ListView( controller: scrollController, padding: const EdgeInsets.symmetric(horizontal: 20), children: [ - if (contact.description != null && contact.description!.isNotEmpty) Text( @@ -3545,7 +3559,6 @@ class ContactProfileDialog extends StatelessWidget { }, ) else - const SizedBox(height: 16), if (!isChannel) @@ -3900,7 +3913,6 @@ class _RemoveMemberDialogState extends State<_RemoveMemberDialog> { } } - class _PromoteAdminDialog extends StatelessWidget { final List> members; final Function(int) onPromoteToAdmin; @@ -3964,7 +3976,6 @@ class _ControlMessageChip extends StatelessWidget { }); String _formatControlMessage() { - final controlAttach = message.attaches.firstWhere( (a) => a['_type'] == 'CONTROL', ); @@ -3974,7 +3985,6 @@ class _ControlMessageChip extends StatelessWidget { final isMe = message.senderId == myId; final senderDisplayName = isMe ? 'Вы' : senderName; - String _formatUserList(List userIds) { if (userIds.isEmpty) { return ''; @@ -4120,7 +4130,6 @@ class _ControlMessageChip extends StatelessWidget { return '$senderName присоединился(ась) к группе'; default: - final eventTypeStr = eventType?.toString() ?? 'неизвестное'; return 'Событие: $eventTypeStr'; } @@ -4153,15 +4162,12 @@ class _ControlMessageChip extends StatelessWidget { } void openUserProfileById(BuildContext context, int userId) { - final contact = ApiService.instance.getCachedContact(userId); if (contact != null) { - final isGroup = contact.id < 0; // Groups have negative IDs if (isGroup) { - showModalBottomSheet( context: context, isScrollControlled: true, @@ -4169,7 +4175,6 @@ void openUserProfileById(BuildContext context, int userId) { builder: (context) => GroupProfileDraggableDialog(contact: contact), ); } else { - Navigator.of(context).push( PageRouteBuilder( opaque: false, @@ -4185,7 +4190,6 @@ void openUserProfileById(BuildContext context, int userId) { ); } } else { - showDialog( context: context, builder: (context) => AlertDialog( diff --git a/lib/chats_screen.dart b/lib/chats_screen.dart index d82d27c..7c34b6a 100644 --- a/lib/chats_screen.dart +++ b/lib/chats_screen.dart @@ -24,6 +24,7 @@ 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/widgets/message_preview_dialog.dart'; class SearchResult { final Chat chat; @@ -237,12 +238,10 @@ class _ChatsScreenState extends State ); } - void _listenForUpdates() { _apiSubscription = ApiService.instance.messages.listen((message) { if (!mounted) return; - if (message['type'] == 'invalid_token') { print( 'Получено событие недействительного токена, перенаправляем на вход', @@ -254,18 +253,22 @@ class _ChatsScreenState extends State } final opcode = message['opcode']; + final cmd = message['cmd']; final payload = message['payload']; if (payload == null) return; final chatIdValue = payload['chatId']; - if (chatIdValue == null) return; - final int chatId = chatIdValue; + final int? chatId = chatIdValue != null ? chatIdValue as int? : null; - if (opcode == 129) { + if (opcode == 272 || opcode == 274) { + } else if (chatId == null) { + return; + } + + if (opcode == 129 && chatId != null) { _setTypingForChat(chatId); } - - if (opcode == 128) { + if (opcode == 128 && chatId != null) { final newMessage = Message.fromJson(payload['message']); ApiService.instance.clearCacheForChat(chatId); @@ -284,10 +287,8 @@ class _ChatsScreenState extends State if (_isSavedMessages(updatedChat)) { if (updatedChat.id == 0) { - _allChats.insert(0, updatedChat); } else { - final savedIndex = _allChats.indexWhere( (c) => _isSavedMessages(c) && c.id == 0, ); @@ -295,7 +296,6 @@ class _ChatsScreenState extends State _allChats.insert(insertIndex, updatedChat); } } else { - final savedIndex = _allChats.indexWhere( (c) => _isSavedMessages(c), ); @@ -305,9 +305,7 @@ class _ChatsScreenState extends State _filterChats(); }); } - } - - else if (opcode == 67) { + } else if (opcode == 67 && chatId != null) { final editedMessage = Message.fromJson(payload['message']); ApiService.instance.clearCacheForChat(chatId); @@ -322,10 +320,8 @@ class _ChatsScreenState extends State if (_isSavedMessages(updatedChat)) { if (updatedChat.id == 0) { - _allChats.insert(0, updatedChat); } else { - final savedIndex = _allChats.indexWhere( (c) => _isSavedMessages(c) && c.id == 0, ); @@ -343,9 +339,7 @@ class _ChatsScreenState extends State }); } } - } - - else if (opcode == 66) { + } else if (opcode == 66 && chatId != null) { final deletedMessageIds = List.from( payload['messageIds'] ?? [], ); @@ -356,7 +350,6 @@ class _ChatsScreenState extends State final oldChat = _allChats[chatIndex]; if (deletedMessageIds.contains(oldChat.lastMessage.id)) { - ApiService.instance.getChatsAndContacts(force: true).then((data) { if (mounted) { final chats = data['chats'] as List; @@ -380,23 +373,19 @@ class _ChatsScreenState extends State } } - - if (opcode == 129) { + 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; // Конвертируем в секунды @@ -444,20 +433,17 @@ class _ChatsScreenState extends State } } - if (opcode == 36 && payload['contacts'] != null) { final List 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}', @@ -465,7 +451,6 @@ class _ChatsScreenState extends State ApiService.instance.notifyContactUpdate(blockedContact); } else { - _contacts[blockedContact.id] = blockedContact; print( 'Добавлен новый заблокированный контакт: ${blockedContact.name}', @@ -478,14 +463,12 @@ class _ChatsScreenState extends State if (mounted) setState(() {}); } - if (opcode == 48) { print('Получен ответ на создание группы: $payload'); _refreshChats(); } - if (opcode == 272) { print('Получен ответ на обновление папок: $payload'); @@ -502,7 +485,11 @@ class _ChatsScreenState extends State if (mounted) { setState(() { _folders = folders; + final foldersOrder = + payload['foldersOrder'] as List?; + _sortFoldersByOrder(foldersOrder); }); + _updateFolderTabController(); _filterChats(); } } @@ -514,6 +501,54 @@ class _ChatsScreenState extends State } } + if (opcode == 274 && cmd == 1) { + print('Получен ответ на создание/обновление папки: $payload'); + + try { + final folderJson = payload['folder'] as Map?; + 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?; + _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 (message['type'] == 'channels_found') { final payload = message['payload']; @@ -544,7 +579,6 @@ class _ChatsScreenState extends State } void _refreshChats() { - _chatsFuture = ApiService.instance.getChatsAndContacts(force: true); _chatsFuture.then((data) { if (mounted) { @@ -578,7 +612,6 @@ class _ChatsScreenState extends State ), child: Column( children: [ - Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -622,7 +655,6 @@ class _ChatsScreenState extends State ), ), - Expanded(child: _buildChannelsList()), ], ), @@ -633,7 +665,6 @@ class _ChatsScreenState extends State if (_channelsLoaded) return; try { - await ApiService.instance.searchChannels('каналы'); _channelsLoaded = true; } catch (e) { @@ -645,7 +676,6 @@ class _ChatsScreenState extends State final colors = Theme.of(context).colorScheme; if (_channels.isEmpty) { - return ListView( padding: const EdgeInsets.all(8), children: [ @@ -688,7 +718,6 @@ class _ChatsScreenState extends State ); } - return ListView.builder( padding: const EdgeInsets.all(8), itemCount: _channels.length, @@ -730,7 +759,6 @@ class _ChatsScreenState extends State overflow: TextOverflow.ellipsis, ), onTap: () { - ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Открытие канала: $title'), @@ -804,7 +832,6 @@ class _ChatsScreenState extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( width: 40, height: 4, @@ -822,7 +849,6 @@ class _ChatsScreenState extends State ), const SizedBox(height: 20), - ListTile( leading: CircleAvatar( backgroundColor: Theme.of( @@ -841,7 +867,6 @@ class _ChatsScreenState extends State }, ), - ListTile( leading: CircleAvatar( backgroundColor: Theme.of( @@ -864,7 +889,6 @@ class _ChatsScreenState extends State }, ), - ListTile( leading: CircleAvatar( backgroundColor: Theme.of( @@ -887,7 +911,6 @@ class _ChatsScreenState extends State }, ), - ListTile( leading: CircleAvatar( backgroundColor: Theme.of( @@ -910,7 +933,6 @@ class _ChatsScreenState extends State }, ), - ListTile( leading: CircleAvatar( backgroundColor: Theme.of( @@ -947,7 +969,6 @@ class _ChatsScreenState extends State final int? myId = _myProfile?.id; - final List availableContacts = _contacts.values.where((contact) { final contactNameLower = contact.name.toLowerCase(); return contactNameLower != 'max' && @@ -1035,10 +1056,38 @@ class _ChatsScreenState extends State } 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? 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 data) { try { final config = data['config'] as Map?; @@ -1055,26 +1104,19 @@ class _ChatsScreenState extends State .toList(); setState(() { - final oldIndex = _folderTabController.index; _folders = folders; - 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); - } + + final foldersOrder = chatFolders['foldersOrder'] as List?; + _sortFoldersByOrder(foldersOrder); + + _updateFolderTabController(); if (_selectedFolderId == null) { if (_folderTabController.index != 0) { _folderTabController.animateTo(0); } } else { - final folderIndex = folders.indexWhere( + final folderIndex = _folders.indexWhere( (f) => f.id == _selectedFolderId, ); if (folderIndex != -1) { @@ -1197,7 +1239,6 @@ class _ChatsScreenState extends State return 0; // Остальные чаты сохраняют порядок }); } else if (_searchFocusNode.hasFocus && query.isEmpty) { - _filteredChats = []; } else if (query.isNotEmpty) { _filteredChats = chatsToFilter.where((chat) { @@ -1227,7 +1268,6 @@ class _ChatsScreenState extends State return 0; }); } else { - _filteredChats = []; } }); @@ -1261,9 +1301,7 @@ class _ChatsScreenState extends State return; } - setState(() { - - }); + setState(() {}); final results = []; final query = _searchQuery.toLowerCase(); @@ -1293,7 +1331,6 @@ class _ChatsScreenState extends State if (contact == null) continue; - if (contact.name.toLowerCase().contains(query)) { results.add( SearchResult( @@ -1306,7 +1343,6 @@ class _ChatsScreenState extends State continue; } - if (contact.description != null && contact.description?.toLowerCase().contains(query) == true) { results.add( @@ -1320,7 +1356,6 @@ class _ChatsScreenState extends State continue; } - if (chat.lastMessage.text.toLowerCase().contains(query) || (chat.lastMessage.text.contains("welcome.saved.dialog.message") && 'привет избранные майор'.contains(query.toLowerCase()))) { @@ -1338,10 +1373,8 @@ class _ChatsScreenState extends State } } - List filteredResults = results; if (_searchFilter == 'recent') { - final weekAgo = DateTime.now().subtract(const Duration(days: 7)); filteredResults = results.where((result) { final lastMessageTime = DateTime.fromMillisecondsSinceEpoch( @@ -1401,7 +1434,6 @@ class _ChatsScreenState extends State final orderedChats = []; final remainingChats = List.from(_allChats); - for (final id in chatIds) { final chatIndex = remainingChats.indexWhere((chat) => chat.id == id); if (chatIndex != -1) { @@ -1409,7 +1441,6 @@ class _ChatsScreenState extends State } } - orderedChats.addAll(remainingChats); _allChats = orderedChats; @@ -1532,14 +1563,12 @@ class _ChatsScreenState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator( strokeWidth: 3, valueColor: AlwaysStoppedAnimation(colors.primary), ), const SizedBox(height: 24), - Text( 'Подключение', style: TextStyle( @@ -1550,7 +1579,6 @@ class _ChatsScreenState extends State ), const SizedBox(height: 8), - Text( 'Устанавливаем соединение с сервером...', style: TextStyle(fontSize: 14, color: colors.onSurfaceVariant), @@ -1591,15 +1619,12 @@ class _ChatsScreenState extends State ); _contacts = {for (var c in contacts) c.id: c}; - final presence = snapshot.data!['presence'] as Map?; if (presence != null) { print('Получен presence: $presence'); - } - if (!_hasRequestedBlockedContacts) { _hasRequestedBlockedContacts = true; ApiService.instance.getBlockedContacts(); @@ -1607,7 +1632,6 @@ class _ChatsScreenState extends State _loadFolders(snapshot.data!); - _loadChatOrder().then((_) { setState(() { _filteredChats = List.from(_allChats); @@ -1615,11 +1639,9 @@ class _ChatsScreenState extends State }); } if (_filteredChats.isEmpty && _allChats.isEmpty) { - return const Center(child: CircularProgressIndicator()); } - if (_isSearchExpanded) { return _buildSearchResults(); } else { @@ -1674,7 +1696,6 @@ class _ChatsScreenState extends State final isDarkMode = themeProvider.themeMode == ThemeMode.dark; return Drawer( - child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1695,7 +1716,6 @@ class _ChatsScreenState extends State mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( radius: 30, // Чуть крупнее backgroundColor: colors.primary, @@ -1742,7 +1762,6 @@ class _ChatsScreenState extends State ), const SizedBox(height: 12), - Text( _myProfile?.displayName ?? 'Загрузка...', style: TextStyle( @@ -1753,7 +1772,6 @@ class _ChatsScreenState extends State ), const SizedBox(height: 4), - Text( _myProfile?.formattedPhone ?? '', style: TextStyle( @@ -1767,7 +1785,6 @@ class _ChatsScreenState extends State Expanded( child: Column( - children: [ ListTile( leading: const Icon(Icons.person_outline), @@ -1795,7 +1812,6 @@ class _ChatsScreenState extends State onTap: () { Navigator.pop(context); // Закрыть Drawer - final screenSize = MediaQuery.of(context).size; final screenWidth = screenSize.width; final screenHeight = screenSize.height; @@ -1808,7 +1824,6 @@ class _ChatsScreenState extends State ); if (isDesktopOrTablet) { - showDialog( context: context, barrierDismissible: true, @@ -1820,7 +1835,6 @@ class _ChatsScreenState extends State ), ); } else { - Navigator.of(context).push( MaterialPageRoute( builder: (context) => SettingsScreen( @@ -1861,7 +1875,6 @@ class _ChatsScreenState extends State if (_searchQuery.isEmpty) { return Column( children: [ - _buildRecentChatsIcons(), const Divider(height: 1), @@ -2338,12 +2351,16 @@ class _ChatsScreenState extends State return ListView.builder( itemCount: chatsForFolder.length, itemBuilder: (context, index) { - return _buildChatListItem(chatsForFolder[index], 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 tabs = [ @@ -2377,24 +2394,252 @@ class _ChatsScreenState extends State bottom: BorderSide(color: colors.outline.withOpacity(0.2), width: 1), ), ), - 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), + 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(); + } + }, ), - indicatorSize: TabBarIndicatorSize.label, - labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), - unselectedLabelStyle: const TextStyle( - fontWeight: FontWeight.normal, - fontSize: 14, + 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 _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); + }, + ), + 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), + ], + ), + ); + }, + ); + } + + 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), ), - dividerColor: Colors.transparent, - tabs: tabs, - onTap: (index) {}, + ); + return; + } + + final newInclude = List.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), ), ); } @@ -2421,7 +2666,6 @@ class _ChatsScreenState extends State child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () async { - SchedulerBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { @@ -2602,7 +2846,7 @@ class _ChatsScreenState extends State : [ IconButton( icon: Image.asset( - 'assets/images/spermum.webp', + 'assets/images/spermum.png', width: 28, height: 28, ), @@ -2621,7 +2865,6 @@ class _ChatsScreenState extends State tooltip: 'Загрузки', ), InkWell( - onTap: () { setState(() { _isSearchExpanded = true; @@ -2795,32 +3038,25 @@ class _ChatsScreenState extends State 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']; @@ -2828,12 +3064,10 @@ class _ChatsScreenState extends State }).toList(); if (callAttachments.isNotEmpty) { - return _buildCallPreview(callAttachments.first, message, chat); } } - if (message.text.isEmpty && message.attaches.isNotEmpty) { return Text('Вложение', maxLines: 1, overflow: TextOverflow.ellipsis); } @@ -2855,10 +3089,8 @@ class _ChatsScreenState extends State IconData callIcon; Color? callColor; - switch (hangupType) { case 'HUNGUP': - final minutes = duration ~/ 60000; final seconds = (duration % 60000) ~/ 1000; final durationText = minutes > 0 @@ -2872,7 +3104,6 @@ class _ChatsScreenState extends State break; case 'MISSED': - final callTypeText = callType == 'VIDEO' ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; @@ -2882,7 +3113,6 @@ class _ChatsScreenState extends State break; case 'CANCELED': - final callTypeText = callType == 'VIDEO' ? 'Видеозвонок отменен' : 'Звонок отменен'; @@ -2892,7 +3122,6 @@ class _ChatsScreenState extends State break; case 'REJECTED': - final callTypeText = callType == 'VIDEO' ? 'Видеозвонок отклонен' : 'Звонок отклонен'; @@ -2902,7 +3131,6 @@ class _ChatsScreenState extends State break; default: - callText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок'; callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call; callColor = colors.onSurfaceVariant; @@ -2925,7 +3153,7 @@ class _ChatsScreenState extends State ); } - Widget _buildChatListItem(Chat chat, int index) { + Widget _buildChatListItem(Chat chat, int index, ChatFolder? currentFolder) { final colors = Theme.of(context).colorScheme; final bool isSavedMessages = _isSavedMessages(chat); @@ -2969,7 +3197,6 @@ class _ChatsScreenState extends State return ListTile( key: ValueKey(chat.id), - onTap: () { final theme = context.read(); if (theme.debugReadOnEnter) { @@ -3003,7 +3230,6 @@ class _ChatsScreenState extends State isBlockedByMe: false, ); - final participantCount = chat.participantsCount ?? chat.participantIds.length; @@ -3036,22 +3262,25 @@ class _ChatsScreenState extends State leading: Stack( clipBehavior: Clip.none, children: [ - CircleAvatar( - radius: 24, - backgroundColor: colors.primaryContainer, + GestureDetector( + onLongPress: () => _showMessagePreview(chat, currentFolder), + child: CircleAvatar( + radius: 24, + backgroundColor: colors.primaryContainer, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + 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, + 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, @@ -3241,7 +3470,6 @@ class _SferumWebViewPanelState extends State { ), child: Column( children: [ - Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -3255,7 +3483,7 @@ class _SferumWebViewPanelState extends State { child: Row( children: [ Image.asset( - 'assets/images/spermum.webp', + 'assets/images/spermum.png', width: 28, height: 28, ), diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 46e19e2..87e04e8 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -1,4 +1,3 @@ -import 'dart:core'; import 'package:flutter/material.dart'; import 'dart:io' show File; import 'dart:convert' show base64Decode; @@ -24,7 +23,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:open_file/open_file.dart'; import 'package:gwid/full_screen_video_player.dart'; - +// Кэш для уже вычисленных цветов final _userColorCache = {}; bool _currentIsDark = false; @@ -34,7 +33,7 @@ enum MessageReadStatus { read, // Прочитано (2 галочки) } - +// Service для отслеживания прогресса загрузки файлов class FileDownloadProgressService { static final FileDownloadProgressService _instance = FileDownloadProgressService._internal(); @@ -44,17 +43,17 @@ class FileDownloadProgressService { final Map> _progressNotifiers = {}; bool _initialized = false; - + // Initialize on first access to load saved download status Future _ensureInitialized() async { if (_initialized) return; try { final prefs = await SharedPreferences.getInstance(); - + // Load fileId -> filePath mappings final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; - + // Mark all downloaded files as completed (progress = 1.0) for (final mapping in fileIdMap) { final parts = mapping.split(':'); if (parts.length >= 2) { @@ -103,20 +102,20 @@ class FileDownloadProgressService { Color _getUserColor(int userId, BuildContext context) { final bool isDark = Theme.of(context).brightness == Brightness.dark; - + // Очищаем кэш при смене темы if (isDark != _currentIsDark) { _userColorCache.clear(); _currentIsDark = isDark; } - + // Возвращаем из кэша, если уже вычисляли if (_userColorCache.containsKey(userId)) { return _userColorCache[userId]!; } final List materialYouColors = isDark ? [ - + // Темная тема const Color(0xFFEF5350), // Красный const Color(0xFFEC407A), // Розовый const Color(0xFFAB47BC), // Фиолетовый @@ -140,7 +139,7 @@ Color _getUserColor(int userId, BuildContext context) { const Color(0xFFC5E1A5), // Светло-зеленый пастельный ] : [ - + // Светлая тема const Color(0xFFF44336), // Красный const Color(0xFFE91E63), // Розовый const Color(0xFF9C27B0), // Фиолетовый @@ -167,7 +166,7 @@ Color _getUserColor(int userId, BuildContext context) { final colorIndex = userId % materialYouColors.length; final color = materialYouColors[colorIndex]; - + // Сохраняем в кэш _userColorCache[userId] = color; return color; @@ -191,6 +190,7 @@ class ChatMessageBubble extends StatelessWidget { final bool isChannel; final String? senderName; final String? forwardedFrom; + final String? forwardedFromAvatarUrl; final Map? contactDetailsCache; final Function(String)? onReplyTap; final bool useAutoReplyColor; @@ -220,6 +220,7 @@ class ChatMessageBubble extends StatelessWidget { this.isChannel = false, this.senderName, this.forwardedFrom, + this.forwardedFromAvatarUrl, this.contactDetailsCache, this.onReplyTap, this.useAutoReplyColor = true, @@ -268,6 +269,32 @@ class ChatMessageBubble extends StatelessWidget { .toList() ?? []; + String forwardedSenderName; + String? forwardedSenderAvatarUrl = forwardedFromAvatarUrl; + + if (forwardedFrom != null) { + forwardedSenderName = forwardedFrom!; + } else { + final chatName = link['chatName'] as String?; + final chatIconUrl = link['chatIconUrl'] as String?; + + if (chatName != null) { + forwardedSenderName = chatName; + forwardedSenderAvatarUrl ??= chatIconUrl; + } else { + final originalSenderId = forwardedMessage['sender'] as int?; + final cache = contactDetailsCache; + if (originalSenderId != null && cache != null) { + final originalSenderContact = cache[originalSenderId]; + forwardedSenderName = + originalSenderContact?.name ?? 'Участник $originalSenderId'; + forwardedSenderAvatarUrl ??= originalSenderContact?.photoBaseUrl; + } else { + forwardedSenderName = 'Неизвестный'; + } + } + } + return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( @@ -282,7 +309,7 @@ class ChatMessageBubble extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - + // "Заголовок" с именем автора и аватаркой Row( mainAxisSize: MainAxisSize.min, children: [ @@ -292,10 +319,61 @@ class ChatMessageBubble extends StatelessWidget { color: textColor.withOpacity(0.6 * messageTextOpacity), ), const SizedBox(width: 6), + if (forwardedSenderAvatarUrl != null) + Container( + width: 20, + height: 20, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: textColor.withOpacity(0.2 * messageTextOpacity), + width: 1, + ), + ), + child: ClipOval( + child: Image.network( + forwardedSenderAvatarUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: textColor.withOpacity( + 0.1 * messageTextOpacity, + ), + child: Icon( + Icons.person, + size: 12, + color: textColor.withOpacity( + 0.5 * messageTextOpacity, + ), + ), + ); + }, + ), + ), + ) + else + Container( + width: 20, + height: 20, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: textColor.withOpacity(0.1 * messageTextOpacity), + border: Border.all( + color: textColor.withOpacity(0.2 * messageTextOpacity), + width: 1, + ), + ), + child: Icon( + Icons.person, + size: 12, + color: textColor.withOpacity(0.5 * messageTextOpacity), + ), + ), Flexible( child: Text( - - forwardedFrom ?? 'Неизвестный', + forwardedSenderName, style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, @@ -308,7 +386,7 @@ class ChatMessageBubble extends StatelessWidget { ), const SizedBox(height: 6), - + // Содержимое пересланного сообщения (фото и/или текст) if (attaches.isNotEmpty) ...[ ..._buildPhotosWithCaption( context, @@ -341,9 +419,9 @@ class ChatMessageBubble extends StatelessWidget { }) { final borderRadius = BorderRadius.circular(12); - + // Логика открытия плеера void openFullScreenVideo() async { - + // Показываем индикатор загрузки, пока получаем URL showDialog( context: context, barrierDismissible: false, @@ -377,7 +455,7 @@ class ChatMessageBubble extends StatelessWidget { } } - + // Виджет-контейнер (GestureDetector + Stack) return GestureDetector( onTap: openFullScreenVideo, child: AspectRatio( @@ -388,8 +466,8 @@ class ChatMessageBubble extends StatelessWidget { alignment: Alignment.center, fit: StackFit.expand, children: [ - - + // [!code ++] (НОВЫЙ БЛОК) + // Если у нас есть ХОТЬ ЧТО-ТО (блюр или URL), показываем ProgressiveImage (highQualityUrl != null && highQualityUrl.isNotEmpty) || (lowQualityBytes != null) ? _ProgressiveNetworkImage( @@ -402,7 +480,7 @@ class ChatMessageBubble extends StatelessWidget { fit: BoxFit.cover, keepAlive: false, ) - + // ИНАЧЕ показываем нашу стандартную заглушку (а не пустоту) : Container( color: Colors.black26, child: const Center( @@ -413,12 +491,12 @@ class ChatMessageBubble extends StatelessWidget { ), ), ), + // [!code ++] (КОНЕЦ НОВОГО БЛОКА) - - + // Иконка Play поверх (она будет поверх заглушки или картинки) Container( decoration: BoxDecoration( - + // Небольшое затемнение, чтобы иконка была виднее color: Colors.black.withOpacity(0.15), ), child: Icon( @@ -467,20 +545,20 @@ class ChatMessageBubble extends StatelessWidget { (isDarkMode ? const Color(0xFF90CAF9) : const Color(0xFF1976D2)); } - + // Вычисляем оптимальную ширину на основе длины текста final textLength = replyText.length; final minWidth = 120.0; // Минимальная ширина для коротких сообщений - + // Адаптивная ширина: минимум 120px, растет в зависимости от длины текста double adaptiveWidth = minWidth; if (textLength > 0) { - + // Базовый расчет: примерно 8px на символ + отступы adaptiveWidth = (textLength * 8.0 + 32).clamp(minWidth, double.infinity); } return GestureDetector( onTap: () { - + // Вызываем callback для прокрутки к оригинальному сообщению if (replyMessageId != null && onReplyTap != null) { onReplyTap!(replyMessageId); } @@ -511,7 +589,7 @@ class ChatMessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ - + // Ник автора сообщения Row( mainAxisSize: MainAxisSize.min, children: [ @@ -535,7 +613,7 @@ class ChatMessageBubble extends StatelessWidget { ], ), const SizedBox(height: 2), - + // Текст сообщения Align( alignment: Alignment.centerLeft, child: Text( @@ -554,7 +632,7 @@ class ChatMessageBubble extends StatelessWidget { } /* void _showMessageContextMenu(BuildContext context) { - + // Список реакций, отсортированный по популярности const reactions = [ '👍', '❤️', @@ -617,7 +695,7 @@ class ChatMessageBubble extends StatelessWidget { '👁️', ]; - + // Проверяем, есть ли уже реакция от пользователя final hasUserReaction = message.reactionInfo != null && message.reactionInfo!['yourReaction'] != null; @@ -635,9 +713,9 @@ class ChatMessageBubble extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - + // Реакции if (onReaction != null) ...[ - + // Контейнер для прокручиваемого списка эмодзи SizedBox( height: 80, // Задаем высоту для ряда с реакциями child: SingleChildScrollView( @@ -669,7 +747,7 @@ class ChatMessageBubble extends StatelessWidget { ), ), ), - + // Кнопка удаления реакции, если есть реакция от пользователя if (hasUserReaction && onRemoveReaction != null) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), @@ -696,7 +774,7 @@ class ChatMessageBubble extends StatelessWidget { ], const Divider(height: 1), ], - + // Действия с сообщением (остаются без изменений) if (onReply != null) ListTile( leading: const Icon(Icons.reply), @@ -832,10 +910,10 @@ class ChatMessageBubble extends StatelessWidget { return GestureDetector( onTap: () { if (isUserReaction) { - + // Если это наша реакция - удаляем onRemoveReaction?.call(); } else { - + // Если это чужая реакция - добавляем такую же onReaction?.call(emoji); } }, @@ -995,7 +1073,7 @@ class ChatMessageBubble extends StatelessWidget { List> attaches, Color textColor, ) { - + // 1. Ищем вложение с клавиатурой final keyboardAttach = attaches.firstWhere( (a) => a['_type'] == 'INLINE_KEYBOARD', orElse: () => @@ -1006,7 +1084,7 @@ class ChatMessageBubble extends StatelessWidget { return []; // Нет клавиатуры } - + // 2. Парсим структуру кнопок final keyboardData = keyboardAttach['keyboard'] as Map?; final buttonRows = keyboardData?['buttons'] as List?; @@ -1016,19 +1094,19 @@ class ChatMessageBubble extends StatelessWidget { final List rows = []; - + // 3. Создаем виджеты для каждого ряда кнопок for (final row in buttonRows) { if (row is List && row.isNotEmpty) { final List buttonsInRow = []; - + // 4. Создаем виджеты для каждой кнопки в ряду for (final buttonData in row) { if (buttonData is Map) { final String? text = buttonData['text'] as String?; final String? type = buttonData['type'] as String?; final String? url = buttonData['url'] as String?; - + // Нас интересуют только кнопки-ссылки (как в вашем JSON) if (text != null && type == 'LINK' && url != null) { buttonsInRow.add( Expanded( @@ -1042,7 +1120,7 @@ class ChatMessageBubble extends StatelessWidget { horizontal: 8, vertical: 12, ), - + // Стилизуем под цвет сообщения backgroundColor: textColor.withOpacity(0.1), foregroundColor: textColor.withOpacity(0.9), ), @@ -1059,7 +1137,7 @@ class ChatMessageBubble extends StatelessWidget { } } - + // Добавляем готовый ряд кнопок if (buttonsInRow.isNotEmpty) { rows.add( Padding( @@ -1074,7 +1152,7 @@ class ChatMessageBubble extends StatelessWidget { } } - + // Возвращаем Column с рядами кнопок if (rows.isNotEmpty) { return [ Padding( @@ -1087,7 +1165,7 @@ class ChatMessageBubble extends StatelessWidget { return []; } - + // Helper-метод для открытия ссылок Future _launchURL(BuildContext context, String url) async { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { @@ -1135,7 +1213,7 @@ class ChatMessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isMe && isGroupChat && !isChannel) ...[ - + //шлем в пезду аватарку если это я, анал. SizedBox( width: 40, child: @@ -1164,7 +1242,7 @@ class ChatMessageBubble extends StatelessWidget { : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - + // Имя отправителя if (isGroupChat && !isMe && senderName != null) Padding( padding: const EdgeInsets.only(left: 2.0, bottom: 2.0), @@ -1385,7 +1463,7 @@ class ChatMessageBubble extends StatelessWidget { if (photos.isEmpty) return widgets; - + // Умная группировка фотографий widgets.add( _buildSmartPhotoGroup(context, photos, textColor, isUltraOptimized), ); @@ -1408,13 +1486,13 @@ class ChatMessageBubble extends StatelessWidget { if (videos.isEmpty) return widgets; for (final video in videos) { - + // 1. Извлекаем все, что нам нужно final videoId = video['videoId'] as int?; final previewData = video['previewData'] as String?; // Блюр-превью final thumbnailUrl = video['url'] ?? video['baseUrl'] as String?; // HQ-превью URL - + // 2. Декодируем блюр-превью Uint8List? previewBytes; if (previewData != null && previewData.startsWith('data:')) { final idx = previewData.indexOf('base64,'); @@ -1426,7 +1504,7 @@ class ChatMessageBubble extends StatelessWidget { } } - + // 3. Формируем URL для HQ-превью (как для фото) String? highQualityThumbnailUrl; if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) { highQualityThumbnailUrl = thumbnailUrl; @@ -1439,7 +1517,7 @@ class ChatMessageBubble extends StatelessWidget { } } - + // 4. Создаем виджет if (videoId != null && chatId != null) { widgets.add( Padding( @@ -1454,7 +1532,7 @@ class ChatMessageBubble extends StatelessWidget { ), ); } else { - + // Заглушка, если вложение есть, а ID не найдены widgets.add( Container( padding: const EdgeInsets.all(16), @@ -1506,7 +1584,7 @@ class ChatMessageBubble extends StatelessWidget { Color textColor, bool isUltraOptimized, ) { - + // Стикеры обычно квадратные, около 200-250px final stickerSize = 250.0; return ConstrainedBox( @@ -1571,10 +1649,10 @@ class ChatMessageBubble extends StatelessWidget { IconData callIcon; Color callColor; - + // Определяем текст, иконку и цвет в зависимости от типа завершения звонка switch (hangupType) { case 'HUNGUP': - + // Звонок был завершен успешно final minutes = duration ~/ 60000; final seconds = (duration % 60000) ~/ 1000; final durationText = minutes > 0 @@ -1588,7 +1666,7 @@ class ChatMessageBubble extends StatelessWidget { break; case 'MISSED': - + // Пропущенный звонок final callTypeText = callType == 'VIDEO' ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; @@ -1598,7 +1676,7 @@ class ChatMessageBubble extends StatelessWidget { break; case 'CANCELED': - + // Звонок отменен final callTypeText = callType == 'VIDEO' ? 'Видеозвонок отменен' : 'Звонок отменен'; @@ -1608,7 +1686,7 @@ class ChatMessageBubble extends StatelessWidget { break; case 'REJECTED': - + // Звонок отклонен final callTypeText = callType == 'VIDEO' ? 'Видеозвонок отклонен' : 'Звонок отклонен'; @@ -1618,7 +1696,7 @@ class ChatMessageBubble extends StatelessWidget { break; default: - + // Неизвестный тип завершения callText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок'; callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call; callColor = textColor.withOpacity(0.6); @@ -1635,7 +1713,7 @@ class ChatMessageBubble extends StatelessWidget { padding: const EdgeInsets.all(12), child: Row( children: [ - + // Call icon Container( width: 48, height: 48, @@ -1646,7 +1724,7 @@ class ChatMessageBubble extends StatelessWidget { child: Icon(callIcon, color: callColor, size: 24), ), const SizedBox(width: 12), - + // Call info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1714,14 +1792,14 @@ class ChatMessageBubble extends StatelessWidget { ) { final borderRadius = BorderRadius.circular(isUltraOptimized ? 8 : 12); - + // Get file extension final extension = _getFileExtension(fileName); final iconData = _getFileIcon(extension); - + // Format file size final sizeStr = _formatFileSize(fileSize); - + // Extract file data final fileId = fileData['fileId'] as int?; final token = fileData['token'] as String?; @@ -1738,7 +1816,7 @@ class ChatMessageBubble extends StatelessWidget { padding: const EdgeInsets.all(12), child: Row( children: [ - + // File icon Container( width: 48, height: 48, @@ -1753,7 +1831,7 @@ class ChatMessageBubble extends StatelessWidget { ), ), const SizedBox(width: 12), - + // File info with progress Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1776,7 +1854,7 @@ class ChatMessageBubble extends StatelessWidget { .getProgress(fileId.toString()), builder: (context, progress, child) { if (progress < 0) { - + // Not downloading return Text( sizeStr, style: TextStyle( @@ -1785,7 +1863,7 @@ class ChatMessageBubble extends StatelessWidget { ), ); } else if (progress < 1.0) { - + // Downloading return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1805,7 +1883,7 @@ class ChatMessageBubble extends StatelessWidget { ], ); } else { - + // Completed return Row( children: [ Icon( @@ -1837,7 +1915,7 @@ class ChatMessageBubble extends StatelessWidget { ], ), ), - + // Download icon if (fileId != null) ValueListenableBuilder( valueListenable: FileDownloadProgressService().getProgress( @@ -1932,7 +2010,7 @@ class ChatMessageBubble extends StatelessWidget { String fileName, int? chatId, ) async { - + // 1. Проверяем fileId, он нужен в любом случае if (fileId == null) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -1952,27 +2030,27 @@ class ChatMessageBubble extends StatelessWidget { final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; final fileIdString = fileId.toString(); - + // Ищем запись для нашего fileId final mapping = fileIdMap.firstWhere( (m) => m.startsWith('$fileIdString:'), orElse: () => '', // Возвращаем пустую строку, если не найдено ); if (mapping.isNotEmpty) { - + // Извлекаем путь из 'fileId:path/to/file' final filePath = mapping.substring(fileIdString.length + 1); final file = io.File(filePath); - + // Проверяем, существует ли файл физически if (await file.exists()) { print( 'Файл $fileName (ID: $fileId) найден локально: $filePath. Открываем...', ); - + // Файл существует, открываем его final result = await OpenFile.open(filePath); if (result.type != ResultType.done && context.mounted) { - + // Показываем ошибку, если не удалось открыть (например, нет приложения) ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Не удалось открыть файл: ${result.message}'), @@ -1982,7 +2060,7 @@ class ChatMessageBubble extends StatelessWidget { } return; // Важно: выходим из функции, чтобы не скачивать заново } else { - + // Файл был в списке, но удален. Очистим некорректную запись. print( 'Файл $fileName (ID: $fileId) был в SharedPreferences, но удален. Начинаем загрузку.', ); @@ -1992,10 +2070,10 @@ class ChatMessageBubble extends StatelessWidget { } } catch (e) { print('Ошибка при проверке локального файла: $e. Продолжаем загрузку...'); - + // Если при проверке что-то пошло не так, просто продолжаем и скачиваем файл. } - + // Если файл не найден локально, продолжаем стандартную процедуру скачивания print( 'Файл $fileName (ID: $fileId) не найден. Запрашиваем URL у сервера...', ); @@ -2027,10 +2105,10 @@ class ChatMessageBubble extends StatelessWidget { } try { - + // Request file URL from server using opcode 88 final messageId = message.id; - + // Send request for file URL via WebSocket final seq = ApiService.instance.sendRawRequest(88, { "fileId": fileId, "chatId": chatId, @@ -2049,7 +2127,7 @@ class ChatMessageBubble extends StatelessWidget { return; } - + // Wait for response with opcode 88 final response = await ApiService.instance.messages .firstWhere( (msg) => msg['seq'] == seq && msg['opcode'] == 88, @@ -2071,7 +2149,7 @@ class ChatMessageBubble extends StatelessWidget { throw Exception('Не получена ссылка на файл'); } - + // Download file to Downloads folder with progress await _downloadFile(downloadUrl, fileName, fileId.toString(), context); } catch (e) { if (context.mounted) { @@ -2091,10 +2169,10 @@ class ChatMessageBubble extends StatelessWidget { String fileId, BuildContext context, ) async { - + // Download in background without blocking dialog _startBackgroundDownload(url, fileName, fileId, context); - + // Show immediate success snackbar if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -2111,11 +2189,11 @@ class ChatMessageBubble extends StatelessWidget { String fileId, BuildContext context, ) async { - + // Initialize progress FileDownloadProgressService().updateProgress(fileId, 0.0); try { - + // Get Downloads directory io.Directory? downloadDir; if (io.Platform.isAndroid) { @@ -2124,7 +2202,7 @@ class ChatMessageBubble extends StatelessWidget { final directory = await getApplicationDocumentsDirectory(); downloadDir = directory; } else if (io.Platform.isWindows || io.Platform.isLinux) { - + // For desktop platforms, use Downloads directory final homeDir = io.Platform.environment['HOME'] ?? io.Platform.environment['USERPROFILE'] ?? @@ -2138,11 +2216,11 @@ class ChatMessageBubble extends StatelessWidget { throw Exception('Downloads directory not found'); } - + // Create the file path final filePath = '${downloadDir.path}/$fileName'; final file = io.File(filePath); - + // Download the file with progress tracking final request = http.Request('GET', Uri.parse(url)); final streamedResponse = await request.send(); @@ -2160,21 +2238,21 @@ class ChatMessageBubble extends StatelessWidget { bytes.addAll(chunk); received += chunk.length; - + // Update progress if content length is known if (contentLength > 0) { final progress = received / contentLength; FileDownloadProgressService().updateProgress(fileId, progress); } } - + // Write file to disk final data = Uint8List.fromList(bytes); await file.writeAsBytes(data); - + // Mark as completed FileDownloadProgressService().updateProgress(fileId, 1.0); - + // Save file path and fileId mapping to SharedPreferences for tracking final prefs = await SharedPreferences.getInstance(); final List downloadedFiles = prefs.getStringList('downloaded_files') ?? []; @@ -2183,7 +2261,7 @@ class ChatMessageBubble extends StatelessWidget { await prefs.setStringList('downloaded_files', downloadedFiles); } - + // Also save fileId -> filePath mapping to track downloaded files by fileId final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; final mappingKey = '$fileId:${file.path}'; if (!fileIdMap.contains(mappingKey)) { @@ -2191,7 +2269,7 @@ class ChatMessageBubble extends StatelessWidget { await prefs.setStringList('file_id_to_path_map', fileIdMap); } - + // Show success message if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -2202,7 +2280,7 @@ class ChatMessageBubble extends StatelessWidget { ); } } catch (e) { - + // Clear progress on error FileDownloadProgressService().clearProgress(fileId); if (context.mounted) { @@ -2304,7 +2382,7 @@ class ChatMessageBubble extends StatelessWidget { constraints: const BoxConstraints(maxHeight: 180), child: Row( children: [ - + // Левая большая фотка Expanded( flex: 2, child: RepaintBoundary( @@ -2318,7 +2396,7 @@ class ChatMessageBubble extends StatelessWidget { ), ), const SizedBox(width: 2), - + // Правая колонка с двумя маленькими Expanded( flex: 1, child: Column( @@ -2363,7 +2441,7 @@ class ChatMessageBubble extends StatelessWidget { constraints: const BoxConstraints(maxHeight: 180), child: Column( children: [ - + // Верхний ряд Expanded( child: Row( children: [ @@ -2394,7 +2472,7 @@ class ChatMessageBubble extends StatelessWidget { ), ), const SizedBox(height: 2), - + // Нижний ряд Expanded( child: Row( children: [ @@ -2434,7 +2512,7 @@ class ChatMessageBubble extends StatelessWidget { List> photos, BorderRadius borderRadius, ) { - + // Для 5+ фотографий показываем сетку 2x2 + счетчик return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180), child: Column( @@ -2571,16 +2649,16 @@ class ChatMessageBubble extends StatelessWidget { child = _imagePlaceholder(); } - + // Используем навигатор для перехода на новый полноэкранный виджет Navigator.of(context).push( PageRouteBuilder( opaque: false, // Делаем страницу прозрачной для красивого перехода barrierColor: Colors.black, pageBuilder: (BuildContext context, _, __) { - + // Возвращаем наш новый экран просмотра return FullScreenPhotoViewer(imageChild: child, attach: attach); }, - + // Добавляем плавное появление transitionsBuilder: (_, animation, __, page) { return FadeTransition(opacity: animation, child: page); }, @@ -2589,8 +2667,8 @@ class ChatMessageBubble extends StatelessWidget { } Widget _buildPhotoWidget(BuildContext context, Map attach) { - - + // Сначала обрабатываем локальные данные (base64), если они есть. + // Это обеспечивает мгновенный показ размытого превью. Uint8List? previewBytes; final preview = attach['previewData']; if (preview is String && preview.startsWith('data:')) { @@ -2600,14 +2678,14 @@ class ChatMessageBubble extends StatelessWidget { try { previewBytes = base64Decode(b64); } catch (_) { - + // Ошибка декодирования, ничего страшного } } } final url = attach['url'] ?? attach['baseUrl']; if (url is String && url.isNotEmpty) { - + // Обработка локальных файлов (если фото отправляется с устройства) if (url.startsWith('file://')) { final path = url.replaceFirst('file://', ''); return Image.file( @@ -2621,8 +2699,8 @@ class ChatMessageBubble extends StatelessWidget { ); } - - + // Формируем специальный URL для предпросмотра в чате: + // средний размер, высокое качество, формат JPEG для эффективности. String previewQualityUrl = url; if (!url.contains('?')) { previewQualityUrl = '$url?size=medium&quality=high&format=jpeg'; @@ -2634,7 +2712,7 @@ class ChatMessageBubble extends StatelessWidget { final optimize = themeProvider.optimizeChats || themeProvider.ultraOptimizeChats; - + // Используем наш новый URL для загрузки качественного превью return _ProgressiveNetworkImage( key: ValueKey(previewQualityUrl), // Ключ по новому URL url: previewQualityUrl, // Передаем новый URL @@ -2648,12 +2726,12 @@ class ChatMessageBubble extends StatelessWidget { ); } - + // Если URL нет, но есть base64 данные, покажем их if (previewBytes != null) { return Image.memory(previewBytes, fit: BoxFit.cover, width: 180); } - + // В самом крайнем случае показываем стандартный плейсхолдер return _imagePlaceholder(); } @@ -2667,7 +2745,7 @@ class ChatMessageBubble extends StatelessWidget { ); } - + // Лёгкий прогрессивный загрузчик: показывает превью, тянет оригинал с прогрессом и кэширует в памяти процесса Color _getBubbleColor( bool isMe, @@ -3057,26 +3135,26 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> void initState() { super.initState(); - - - + // [!code ++] (НОВЫЙ БЛОК) + // Если URL пустой, нечего загружать. + // Полагаемся только на previewBytes. if (widget.url.isEmpty) { return; } + // [!code ++] (КОНЕЦ НОВОГО БЛОКА) - - + // Если есть в глобальном кэше — используем сразу final cached = GlobalImageStore.getData(widget.url); if (cached != null) { _fullBytes = cached; - + // no return, продолжаем проверить диск на всякий } - + // Если есть в кэше — используем if (_memoryCache.containsKey(widget.url)) { _fullBytes = _memoryCache[widget.url]; } if (widget.startDownloadNextFrame) { - + // Загружаем в следующем кадре WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _tryLoadFromDiskThenDownload(); }); @@ -3086,14 +3164,14 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> } Future _tryLoadFromDiskThenDownload() async { - - + // [!code ++] (НОВЫЙ БЛОК) + // Не пытаемся грузить, если URL пустой if (widget.url.isEmpty) { return; } + // [!code ++] (КОНЕЦ НОВОГО БЛОКА) - - + // Попытка прочитать из дискового кэша try { final dir = await getTemporaryDirectory(); final name = crypto.md5.convert(widget.url.codeUnits).toString(); @@ -3138,7 +3216,7 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> final data = Uint8List.fromList(bytes); _memoryCache[widget.url] = data; GlobalImageStore.setData(widget.url, data); - + // Пишем на диск try { final path = _diskPath; if (path != null) { @@ -3171,7 +3249,7 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> child: const Icon(Icons.broken_image_outlined, color: Colors.black38), ); } - + // Полное качество есть — показываем return RepaintBoundary( child: SizedBox( width: width, @@ -3183,7 +3261,7 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> child: Stack( fit: StackFit.expand, children: [ - + // 1) Стабильный нижний слой — превью или нейтральный фон if (widget.previewBytes != null) Image.memory( widget.previewBytes!, @@ -3192,15 +3270,15 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> ) else Container(color: Colors.black12), - + // 2) Верхний слой — оригинал. Он появляется, но не убирает превью, чтобы не мигать if (_fullBytes != null) Image.memory( _fullBytes!, fit: widget.fit, filterQuality: FilterQuality.high, ), - - + // нижний прогресс убран, чтобы не перерисовывать слой картинки во время slide; + // прогресс выводится рядом со временем сообщения ], ), ), @@ -3214,7 +3292,7 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> void didUpdateWidget(covariant _ProgressiveNetworkImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.keepAlive != widget.keepAlive) { - + // Пересоберём keepAlive флаг updateKeepAlive(); } } @@ -3257,13 +3335,13 @@ class _CustomEmojiButtonState extends State<_CustomEmojiButton> super.dispose(); } - + // Логика нажатия упрощена void _handleTap() { - + // Анимация масштабирования для обратной связи _scaleController.forward().then((_) { _scaleController.reverse(); }); - + // Сразу открываем диалог _showCustomEmojiDialog(); } @@ -3293,7 +3371,7 @@ class _CustomEmojiButtonState extends State<_CustomEmojiButton> color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), - + // Стрелка заменена на иконку "добавить" child: Icon( Icons.add_reaction_outlined, size: 24, @@ -3469,7 +3547,7 @@ class _MessageContextMenuState extends State<_MessageContextMenu> late AnimationController _animationController; late Animation _scaleAnimation; - + // Короткий список для быстрого доступа static const List _quickReactions = [ '👍', '❤️', @@ -3479,7 +3557,7 @@ class _MessageContextMenuState extends State<_MessageContextMenu> '🤔', ]; - + // Полный список всех реакций static const List _allReactions = [ '👍', '❤️', @@ -3826,14 +3904,14 @@ class FullScreenPhotoViewer extends StatefulWidget { class _FullScreenPhotoViewerState extends State { late final TransformationController _transformationController; - + // Переменная для контроля, можно ли двигать изображение bool _isPanEnabled = false; @override void initState() { super.initState(); _transformationController = TransformationController(); - + // "Слушаем" изменения зума _transformationController.addListener(_onTransformChanged); } @@ -3845,12 +3923,12 @@ class _FullScreenPhotoViewerState extends State { } void _onTransformChanged() { - + // Получаем текущий масштаб final currentScale = _transformationController.value.getMaxScaleOnAxis(); - + // Разрешаем двигать, только если масштаб больше 1 final shouldPan = currentScale > 1.0; - + // Обновляем состояние, только если оно изменилось if (shouldPan != _isPanEnabled) { setState(() { _isPanEnabled = shouldPan; @@ -3862,7 +3940,7 @@ class _FullScreenPhotoViewerState extends State { if (widget.attach == null) return; try { - + // Get Downloads directory io.Directory? downloadDir; if (io.Platform.isAndroid) { @@ -3894,13 +3972,13 @@ class _FullScreenPhotoViewerState extends State { throw Exception('Downloads directory not found'); } - + // Get photo URL final url = widget.attach!['url'] ?? widget.attach!['baseUrl']; if (url == null || url.isEmpty) { throw Exception('Photo URL not found'); } - + // Extract file extension from URL or use .jpg as default String extension = 'jpg'; final uri = Uri.tryParse(url); if (uri != null && uri.pathSegments.isNotEmpty) { @@ -3911,18 +3989,18 @@ class _FullScreenPhotoViewerState extends State { } } - + // Generate filename with timestamp final timestamp = DateTime.now().millisecondsSinceEpoch; final fileName = 'photo_$timestamp.$extension'; final filePath = '${downloadDir.path}/$fileName'; final file = io.File(filePath); - + // Download the image final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { await file.writeAsBytes(response.bodyBytes); - + // Save to SharedPreferences final prefs = await SharedPreferences.getInstance(); final List downloadedFiles = prefs.getStringList('downloaded_files') ?? []; @@ -3974,7 +4052,7 @@ class _FullScreenPhotoViewerState extends State { child: Center(child: widget.imageChild), ), ), - + // Top bar with close button and download button SafeArea( child: Padding( padding: const EdgeInsets.all(16.0), @@ -4018,14 +4096,14 @@ class _RotatingIcon extends StatefulWidget { class _RotatingIconState extends State<_RotatingIcon> with SingleTickerProviderStateMixin { - + // Важно добавить 'with SingleTickerProviderStateMixin' late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( - + // Длительность одного оборота (2 секунды) duration: const Duration(seconds: 2), vsync: this, )..repeat(); // Запускаем анимацию на бесконечное повторение @@ -4039,7 +4117,7 @@ class _RotatingIconState extends State<_RotatingIcon> @override Widget build(BuildContext context) { - + // RotationTransition - это виджет, который вращает своего "ребенка" return RotationTransition( turns: _controller, // Анимация вращения child: Icon(widget.icon, size: widget.size, color: widget.color), diff --git a/lib/widgets/message_preview_dialog.dart b/lib/widgets/message_preview_dialog.dart new file mode 100644 index 0000000..aec191f --- /dev/null +++ b/lib/widgets/message_preview_dialog.dart @@ -0,0 +1,412 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:gwid/models/chat.dart'; +import 'package:gwid/models/message.dart'; +import 'package:gwid/models/contact.dart'; +import 'package:gwid/models/profile.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/widgets/chat_message_bubble.dart'; +import 'package:gwid/chat_screen.dart'; + +class MessagePreviewDialog { + static 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); + } + } + } + + static bool _isSavedMessages(Chat chat) { + return chat.id == 0; + } + + static bool _isGroupChat(Chat chat) { + return chat.type == 'CHAT' || chat.participantIds.length > 2; + } + + static bool _isSameDay(DateTime date1, DateTime date2) { + return date1.year == date2.year && + date1.month == date2.month && + date1.day == date2.day; + } + + static bool _isMessageGrouped( + Message currentMessage, + Message? previousMessage, + ) { + if (previousMessage == null) return false; + + final currentTime = DateTime.fromMillisecondsSinceEpoch( + currentMessage.time, + ); + final previousTime = DateTime.fromMillisecondsSinceEpoch( + previousMessage.time, + ); + + final timeDifference = currentTime.difference(previousTime).inMinutes; + + return currentMessage.senderId == previousMessage.senderId && + timeDifference <= 5; + } + + static String _formatDateSeparator(DateTime date) { + final now = DateTime.now(); + if (_isSameDay(date, now)) { + return 'Сегодня'; + } else { + final yesterday = now.subtract(const Duration(days: 1)); + if (_isSameDay(date, yesterday)) { + return 'Вчера'; + } else { + return DateFormat('d MMM yyyy', 'ru').format(date); + } + } + } + + static String _getChatTitle(Chat chat, Map contacts) { + final bool isSavedMessages = _isSavedMessages(chat); + final bool isGroupChat = _isGroupChat(chat); + final bool isChannel = chat.type == 'CHANNEL'; + + if (isSavedMessages) { + return "Избранное"; + } else if (isChannel) { + return chat.title ?? "Канал"; + } else if (isGroupChat) { + return chat.title?.isNotEmpty == true ? chat.title! : "Группа"; + } else { + final myId = chat.ownerId; + final otherParticipantId = chat.participantIds.firstWhere( + (id) => id != myId, + orElse: () => myId, + ); + final contact = contacts[otherParticipantId]; + return contact?.name ?? "Неизвестный чат"; + } + } + + static Future show( + BuildContext context, + Chat chat, + Map contacts, + Profile? myProfile, + VoidCallback? onClose, + Widget Function(BuildContext)? menuBuilder, + ) async { + final colors = Theme.of(context).colorScheme; + + List messages = []; + bool isLoading = true; + + try { + messages = await ApiService.instance.getMessageHistory( + chat.id, + force: false, + ); + if (messages.length > 10) { + messages = messages.sublist(messages.length - 10); + } + } catch (e) { + print('Ошибка загрузки сообщений для предпросмотра: $e'); + } finally { + isLoading = false; + } + + final Set senderIds = messages.map((m) => m.senderId).toSet(); + senderIds.remove(0); + + final Set forwardedSenderIds = {}; + for (final message in messages) { + if (message.isForwarded && message.link != null) { + final link = message.link; + if (link is Map) { + final chatName = link['chatName'] as String?; + if (chatName == null) { + final forwardedMessage = link['message'] as Map?; + final originalSenderId = forwardedMessage?['sender'] as int?; + if (originalSenderId != null) { + forwardedSenderIds.add(originalSenderId); + } + } + } + } + } + + final allIdsToFetch = { + ...senderIds, + ...forwardedSenderIds, + }.where((id) => !contacts.containsKey(id)).toList(); + + if (allIdsToFetch.isNotEmpty) { + try { + final newContacts = await ApiService.instance.fetchContactsByIds( + allIdsToFetch, + ); + for (final contact in newContacts) { + contacts[contact.id] = contact; + } + } catch (e) { + print('Ошибка загрузки контактов для предпросмотра: $e'); + } + } + + final chatTitle = _getChatTitle(chat, contacts); + final bool isGroupChat = _isGroupChat(chat); + final bool isChannel = chat.type == 'CHANNEL'; + final myId = myProfile?.id ?? chat.ownerId; + + if (!context.mounted) return; + + List chatItems = []; + for (int i = 0; i < messages.length; i++) { + final currentMessage = messages[i]; + final previousMessage = (i > 0) ? messages[i - 1] : null; + + final currentDate = DateTime.fromMillisecondsSinceEpoch( + currentMessage.time, + ).toLocal(); + final previousDate = previousMessage != null + ? DateTime.fromMillisecondsSinceEpoch(previousMessage.time).toLocal() + : null; + + if (previousMessage == null || !_isSameDay(currentDate, previousDate!)) { + chatItems.add(DateSeparatorItem(currentDate)); + } + + final isGrouped = _isMessageGrouped(currentMessage, previousMessage); + final isFirstInGroup = previousMessage == null || !isGrouped; + final isLastInGroup = + i == messages.length - 1 || + !_isMessageGrouped(messages[i + 1], currentMessage); + + chatItems.add( + MessageItem( + currentMessage, + isFirstInGroup: isFirstInGroup, + isLastInGroup: isLastInGroup, + isGrouped: isGrouped, + ), + ); + } + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return DraggableScrollableSheet( + initialChildSize: 0.75, + 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: [ + Expanded( + child: Text( + chatTitle, + 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(); + onClose?.call(); + }, + ), + ], + ), + ), + Expanded( + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : messages.isEmpty + ? Center( + child: Text( + 'Нет сообщений', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + ), + ) + : ListView.builder( + controller: scrollController, + reverse: true, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + itemCount: chatItems.length, + itemBuilder: (context, index) { + final mappedIndex = chatItems.length - 1 - index; + final item = chatItems[mappedIndex]; + + if (item is DateSeparatorItem) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: colors.surfaceVariant, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _formatDateSeparator(item.date), + style: TextStyle( + fontSize: 12, + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ); + } + + if (item is MessageItem) { + final message = item.message; + final isMe = message.senderId == myId; + final senderContact = + contacts[message.senderId]; + final senderName = isMe + ? 'Вы' + : (senderContact?.name ?? 'Неизвестный'); + + String? forwardedFrom; + String? forwardedFromAvatarUrl; + if (message.isForwarded) { + final link = message.link; + if (link is Map) { + final chatName = + link['chatName'] as String?; + final chatIconUrl = + link['chatIconUrl'] as String?; + + if (chatName != null) { + forwardedFrom = chatName; + forwardedFromAvatarUrl = chatIconUrl; + } else { + final forwardedMessage = + link['message'] + as Map?; + final originalSenderId = + forwardedMessage?['sender'] as int?; + if (originalSenderId != null) { + final originalSenderContact = + contacts[originalSenderId]; + forwardedFrom = + originalSenderContact?.name ?? + 'Участник $originalSenderId'; + forwardedFromAvatarUrl = + originalSenderContact?.photoBaseUrl; + } + } + } + } + + return ChatMessageBubble( + message: message, + isMe: isMe, + readStatus: null, + deferImageLoading: true, + myUserId: myId, + chatId: chat.id, + onReply: null, + onEdit: null, + canEditMessage: null, + onDeleteForMe: null, + onDeleteForAll: null, + onReaction: null, + onRemoveReaction: null, + isGroupChat: isGroupChat, + isChannel: isChannel, + senderName: senderName, + forwardedFrom: forwardedFrom, + forwardedFromAvatarUrl: + forwardedFromAvatarUrl, + contactDetailsCache: contacts, + onReplyTap: null, + useAutoReplyColor: false, + customReplyColor: null, + isFirstInGroup: item.isFirstInGroup, + isLastInGroup: item.isLastInGroup, + isGrouped: item.isGrouped, + avatarVerticalOffset: -8.0, + ); + } + + return const SizedBox.shrink(); + }, + ), + ), + if (menuBuilder != null) ...[ + Divider(height: 1, color: colors.outline.withOpacity(0.2)), + menuBuilder(context), + ], + ], + ), + ); + }, + ); + }, + ); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index adca3f8..85e283b 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "Komet") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.gwid.com.gwid") +set(APPLICATION_ID "com.gwid.app.gwid") diff --git a/pubspec.lock b/pubspec.lock index e573b40..687c3a2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -801,10 +801,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1310,10 +1310,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" timezone: dependency: "direct main" description: