diff --git a/.gitignore b/.gitignore index f395ff9..5509b1a 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,9 @@ agents.md # Environment variables .env -.env.* \ No newline at end of file +.env.* +# Локальные дампы трафика и скрипты анализа (содержат секреты) +komet.txt +original_app.txt +fingerprint.py +PCAPdroid_*.txt diff --git a/lib/backend/api.dart b/lib/backend/api.dart index c767808..4221c4f 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -39,6 +39,12 @@ class Api { Map? get userAgent => _userAgent; + int? _callsSeed; + String? _deviceId; + + int? get callsSeed => _callsSeed; + String? get deviceId => _deviceId; + List? _registrationCountries; List get registrationCountries => @@ -111,6 +117,7 @@ class Api { try { final response = await sendHandshake(); if (response.isOk) { + _callsSeed = response.payload['callsSeed'] as int?; _registrationCountries = _parseRegistrationCountries(response.payload); _setSessionState(SessionState.online); _startPinging(); @@ -164,7 +171,7 @@ class Api { String architecture = 'arm64'; String appVersion = SpoofingService.hardcodedAppVersion; int buildNumber = SpoofingService.hardcodedBuildNumber; - String screen = '1920x1080'; + String screen = '420dpi 420dpi 1080x2340'; tz.initializeTimeZones(); final timeZoneName = await FlutterTimezone.getLocalTimezone(); @@ -230,23 +237,25 @@ class Api { _userAgent = { 'deviceType': deviceType, - 'locale': locale, - 'deviceLocale': deviceLocale, - 'osVersion': osVersion, - 'deviceName': deviceName, 'appVersion': appVersion, - 'screen': screen, + 'osVersion': osVersion, 'timezone': timezone, + 'screen': screen, 'pushDeviceType': 'GCM', 'arch': architecture, + 'locale': locale, 'buildNumber': buildNumber, + 'deviceName': deviceName, + 'deviceLocale': deviceLocale, }; + _deviceId = deviceId; + final payload = { 'mt_instanceid': await DeviceIdentity.instanceId(), + 'userAgent': _userAgent, 'clientSessionId': DeviceIdentity.clientSessionId, 'deviceId': deviceId, - 'userAgent': _userAgent, }; return sendRequest(Opcode.sessionInit, payload); diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 67573bb..88cde47 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import '../api.dart'; +import '../../core/protocol/chat_cache_fingerprint.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; @@ -223,6 +225,20 @@ class RequestCodeResult { const RequestCodeResult({required this.token}); } +class PresetAvatar { + final int id; + final String url; + + const PresetAvatar({required this.id, required this.url}); +} + +class PresetAvatarCategory { + final String name; + final List avatars; + + const PresetAvatarCategory({required this.name, required this.avatars}); +} + class VerifyCodeResult { final Map payload; @@ -232,6 +248,37 @@ class VerifyCodeResult { String? get registerToken => _nestedToken('REGISTER'); + bool get isRegistration => registerToken != null && loginToken == null; + + List get presetAvatars { + final raw = payload['presetAvatars']; + if (raw is! List) return const []; + final categories = []; + for (final cat in raw) { + if (cat is! Map) continue; + final avatarsRaw = cat['avatars']; + if (avatarsRaw is! List) continue; + final avatars = []; + for (final a in avatarsRaw) { + if (a is! Map) continue; + final id = a['id']; + final url = a['url']; + if (id is int && url is String && url.isNotEmpty) { + avatars.add(PresetAvatar(id: id, url: url)); + } + } + if (avatars.isNotEmpty) { + categories.add( + PresetAvatarCategory( + name: cat['name']?.toString() ?? '', + avatars: avatars, + ), + ); + } + } + return categories; + } + bool get requiresPassword => payload['passwordChallenge'] != null; Map? get passwordChallenge { @@ -484,7 +531,7 @@ class AccountModule { return newProfile; } - Future updateProfileAvatar(String photoToken, String avatarType) async { + Future updateProfileAvatar(String photoToken, {String avatarType = 'USER_AVATAR'}) async { _ensureOnline(); final packet = await _api.sendRequest(Opcode.profile, { 'photoToken': photoToken, @@ -621,15 +668,17 @@ class AccountModule { String? hint, }) async { _ensureOnline(); + final capabilities = [0, if (hint != null) 3, 4]; final payload = { - 'expectedCapabilities': [0, 3, 4], + 'expectedCapabilities': capabilities, 'trackId': trackId, 'password': password, }; if (hint != null) payload['hint'] = hint; - final packet = await _api.sendRequest(Opcode.authSet2fa, payload); - _checkPacketError(packet, 'confirm2fa'); - return _processProfileUpdate(packet); + return _processProfileUpdate( + _api.sendRequest(Opcode.authSet2fa, payload), + 'confirm2fa', + ); } // 2FA Management (when already set) @@ -670,6 +719,11 @@ class AccountModule { ); } + Future get2faStatus() async { + final trackId = await enter2faPanel(); + return get2faDetails(trackId); + } + Future check2faPassword(String trackId, String password) async { _ensureOnline(); final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, { @@ -707,15 +761,16 @@ class AccountModule { } final payload = { - 'expectedCapabilities': [1, 3], + 'expectedCapabilities': [1, if (hint != null) 3], 'trackId': trackId, 'password': newPassword, }; if (hint != null) payload['hint'] = hint; - final packet = await _api.sendRequest(Opcode.authSet2fa, payload); - _checkPacketError(packet, 'update2faPassword'); - return _processProfileUpdate(packet); + return _processProfileUpdate( + _api.sendRequest(Opcode.authSet2fa, payload), + 'update2faPassword', + ); } Future update2faEmail({ @@ -740,9 +795,10 @@ class AccountModule { 'expectedCapabilities': [4], 'trackId': trackId, }; - final packet = await _api.sendRequest(Opcode.authSet2fa, payload); - _checkPacketError(packet, 'update2faEmail'); - return _processProfileUpdate(packet); + return _processProfileUpdate( + _api.sendRequest(Opcode.authSet2fa, payload), + 'update2faEmail', + ); } Future remove2fa(String trackId) async { @@ -752,34 +808,46 @@ class AccountModule { 'trackId': trackId, 'remove2fa': true, }; - final packet = await _api.sendRequest(Opcode.authSet2fa, payload); - _checkPacketError(packet, 'remove2fa'); - return _processProfileUpdate(packet); + return _processProfileUpdate( + _api.sendRequest(Opcode.authSet2fa, payload), + 'remove2fa', + ); } - Future _processProfileUpdate(Packet packet) async { - _api.registerPushHandler(Opcode.notifProfile, (p) {}); - try { - await for (final push in _api.pushStream - .where((p) => p.opcode == Opcode.notifProfile) - .timeout(const Duration(seconds: 15))) { - final payload = push.payload; - if (payload is Map) { - final profile = payload['profile']; - if (profile is Map) { - final contact = profile['contact']; - if (contact is Map) { - return ProfileData.fromServerMap(contact.cast()); - } - } - } + Future _processProfileUpdate( + Future requestFuture, + String tag, + ) async { + final completer = Completer(); + final sub = _api.pushStream + .where((p) => p.opcode == Opcode.notifProfile) + .listen((push) { + if (completer.isCompleted) return; + final payload = push.payload; + if (payload is! Map) return; + final profile = payload['profile']; + if (profile is! Map) return; + final contact = profile['contact']; + if (contact is! Map) return; + completer.complete( + ProfileData.fromServerMap(contact.cast()), + ); + }); + final timer = Timer(const Duration(seconds: 15), () { + if (!completer.isCompleted) { + completer.completeError( + Exception('Таймаут ожидания обновления профиля'), + ); } - } on TimeoutException { - throw Exception('Таймаут ожидания обновления профиля'); + }); + try { + final packet = await requestFuture; + _checkPacketError(packet, tag); + return await completer.future; } finally { - _api.unregisterPushHandler(Opcode.notifProfile); + timer.cancel(); + await sub.cancel(); } - throw Exception('Не удалось получить обновлённый профиль'); } Future requestCode( @@ -827,6 +895,61 @@ class AccountModule { return result; } + Future completeRegistration({ + required String token, + required String firstName, + String? lastName, + int? photoId, + }) async { + _ensureOnline(); + + final payload = { + 'token': token, + 'tokenType': AuthRequestType.register.value, + 'firstName': firstName, + }; + if (lastName != null && lastName.isNotEmpty) { + payload['lastName'] = lastName; + } + if (photoId != null) { + payload['photoId'] = photoId; + payload['avatarType'] = 'PRESET_AVATAR'; + } + + logger.i('Завершение регистрации (opcode=${Opcode.authConfirm})'); + + final packet = await _api.sendRequest(Opcode.authConfirm, payload); + + _checkPacketError(packet, 'completeRegistration'); + + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'completeRegistration: неожиданный тип payload: ${data.runtimeType}', + ); + } + + final profileMap = data['profile']; + if (profileMap is! Map) { + throw Exception('completeRegistration: отсутствует profile в ответе'); + } + final contact = profileMap['contact']; + if (contact is! Map) { + throw Exception('completeRegistration: отсутствует profile.contact'); + } + final accountId = contact['id'] as int?; + if (accountId == null) { + throw Exception('completeRegistration: отсутствует id аккаунта'); + } + + final profile = ProfileData.fromServerMap(contact.cast()); + await AppDatabase.saveProfile(profile, isActive: true); + await TokenStorage.setActiveAccount(accountId); + + logger.i('Регистрация завершена, accountId=$accountId'); + return accountId; + } + Future login({ int? accountId, String? token, @@ -921,6 +1044,20 @@ class AccountModule { _checkPacketError(packet, 'authorizeWebQrLogin'); } + Future beginAddAccount() async { + try { + await _api.disconnect(); + } catch (_) {} + + await TokenStorage.clearActiveAccount(); + + ContactCache.clear(); + TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); + + logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен'); + } + Future switchAccount(int accountId) async { final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { @@ -940,6 +1077,7 @@ class AccountModule { ContactCache.clear(); TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); await ContactsModule.primeCacheFromDb(accountId); try { @@ -1017,9 +1155,18 @@ class AccountModule { final payload = { 'token': token, 'interactive': true, - if (_api.userAgent != null) 'userAgent': _api.userAgent, + 'exp': { + 'chatsCountGroups': Uint8List.fromList([0x0b, 0x32]), + }, }; + final callsSeed = _api.callsSeed; + final deviceId = _api.deviceId; + if (callsSeed != null && deviceId != null) { + payload['chatCacheFingerprint'] = + ChatCacheFingerprint.compute(callsSeed, deviceId); + } + if (sync != null) { payload['presenceSync'] = sync.presenceSync; payload['chatsSync'] = sync.chatsSync; @@ -1029,9 +1176,6 @@ class AccountModule { payload['bannersSync'] = sync.bannersSync; payload['lastLogin'] = sync.lastLogin; if (sync.configHash != null) payload['configHash'] = sync.configHash; - if (sync.chatCacheFingerprint != null) { - payload['chatCacheFingerprint'] = sync.chatCacheFingerprint; - } } else { payload['presenceSync'] = 0; } diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 96387e6..bcc1a7a 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -5,12 +5,13 @@ import 'package:flutter/foundation.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; +import '../../core/cache/info_cache.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import '../api.dart'; import 'folders.dart'; -import 'messages.dart' show ContactCache; +import 'messages.dart' show ContactCache, CachedMessage; Map _parseParticipants(dynamic raw) { try { @@ -146,27 +147,317 @@ class CachedChat { }; } +sealed class MessageEvent { + final int chatId; + const MessageEvent(this.chatId); +} + +class MessageAddedEvent extends MessageEvent { + final CachedMessage message; + const MessageAddedEvent(super.chatId, this.message); +} + +class MessageEditedEvent extends MessageEvent { + final CachedMessage message; + const MessageEditedEvent(super.chatId, this.message); +} + +class MessageRemovedEvent extends MessageEvent { + final String messageId; + const MessageRemovedEvent(super.chatId, this.messageId); +} + +class MessageReactionsChangedEvent extends MessageEvent { + final String messageId; + final Map? reactionInfo; + const MessageReactionsChangedEvent(super.chatId, this.messageId, this.reactionInfo); +} + class ChatsModule { static const int muteOff = 0; static const int muteForever = -1; + /// Sentinel в `lastMsgText` когда последнее сообщение в чате удалено, + /// а кеша истории нет — UI должен отрисовать курсивную плашку. + static const String lastMsgPlaceholder = '__komet_lastmsg_placeholder__'; + + static final _messageEventsController = + StreamController.broadcast(); + static Stream get messageEvents => + _messageEventsController.stream; + static final ValueNotifier chatsChanged = ValueNotifier(0); static void _bump() => chatsChanged.value = chatsChanged.value + 1; static StreamSubscription? _globalPushSub; + static StreamSubscription? _globalStateSub; + + static final Set _dirtyChats = {}; + static final Set _knownChats = {}; + + static bool isChatDirty(int chatId) => _dirtyChats.contains(chatId); + static void markChatClean(int chatId) => _dirtyChats.remove(chatId); + static void markChatDirty(int chatId) => _dirtyChats.add(chatId); + static void registerKnownChat(int chatId) => _knownChats.add(chatId); static void attachGlobalPushHandlers(Api api) { _globalPushSub?.cancel(); + _globalStateSub?.cancel(); _globalPushSub = api.pushStream.listen(_handleGlobalPush); + _globalStateSub = api.stateStream.listen(_handleSessionState); + if (api.state != SessionState.online) { + _markAllKnownChatsDirty(); + } + } + + static Future _handleSessionState(SessionState state) async { + if (state == SessionState.disconnected) { + ContactInfoFetch.clear(); + PresenceFetch.clear(); + ChatInfoFetch.clear(); + await _markAllKnownChatsDirty(); + } + } + + static void resetForAccountSwitch() { + _dirtyChats.clear(); + _knownChats.clear(); + ContactInfoFetch.clear(); + PresenceFetch.clear(); + ChatInfoFetch.clear(); + } + + static Future _markAllKnownChatsDirty() async { + if (_knownChats.isEmpty) { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final rows = await AppDatabase.loadChats(accountId); + for (final row in rows) { + final id = row['id']; + if (id is int) _knownChats.add(id); + } + } + _dirtyChats.addAll(_knownChats); } static Future _handleGlobalPush(Packet packet) async { switch (packet.opcode) { + case Opcode.notifMessage: + await _handleNotifMessage(packet); case Opcode.notifMark: await _handleNotifMark(packet); + case Opcode.notifMsgReactionsChanged: + await _handleNotifMsgReactionsChanged(packet); } } + static Future _handleNotifMessage(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final msg = payload['message']; + if (msg is! Map) return; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final senderId = msg['sender'] as int?; + final msgIdStr = msg['id']?.toString(); + final msgIdInt = (msg['id'] is int) + ? msg['id'] as int + : int.tryParse(msgIdStr ?? ''); + final msgTime = msg['time'] as int?; + final msgText = msg['text'] as String?; + final status = msg['status'] as String?; + final unread = payload['unread'] as int?; + + var rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) { + try { + final chatInfo = await ChatInfoFetch.get(chatId); + if (chatInfo != null) { + await cacheServerChat(chatInfo, accountId); + } + } catch (e) { + logger.w('notifMessage: fetch info for unknown chat $chatId failed: $e'); + return; + } + rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + } + + if (status == 'REMOVED' && msgIdStr != null) { + await AppDatabase.deleteMessage(accountId, chatId, msgIdStr); + final cachedChat = CachedChat.fromDbRow(rows.first); + if (cachedChat.lastMsgId == msgIdInt) { + await _reconcileLastMessage(accountId, chatId, rows.first, unread: unread); + } else if (unread != null) { + final newRow = Map.from(rows.first); + newRow['unread_count'] = unread; + await AppDatabase.saveChats([newRow]); + } + _messageEventsController.add(MessageRemovedEvent(chatId, msgIdStr)); + _bump(); + return; + } + + CachedMessage? emittedMessage; + if (status == 'EDITED' && msgIdStr != null) { + final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr); + if (existing != null) { + Map mergedPayload; + final existingPayloadRaw = existing['payload']; + if (existingPayloadRaw is String && existingPayloadRaw.isNotEmpty) { + try { + mergedPayload = Map.from( + jsonDecode(existingPayloadRaw) as Map, + ); + } catch (_) { + mergedPayload = Map.from(msg); + } + } else { + mergedPayload = Map.from(msg); + } + for (final entry in msg.entries) { + if (entry.key == 'reactionInfo') continue; + mergedPayload[entry.key.toString()] = entry.value; + } + final newRow = Map.from(existing); + newRow['text'] = msgText; + newRow['status'] = status; + newRow['payload'] = jsonEncode(mergedPayload); + await AppDatabase.saveMessages([newRow]); + emittedMessage = CachedMessage.fromDbRow(newRow); + _messageEventsController.add(MessageEditedEvent(chatId, emittedMessage)); + } + } else if (msgIdStr != null) { + final existing = await AppDatabase.loadMessage(accountId, chatId, msgIdStr); + if (existing == null) { + final cached = CachedMessage.fromPushPayload(accountId, chatId, msg); + await AppDatabase.saveMessages([cached.toDbRow()]); + emittedMessage = cached; + _messageEventsController.add(MessageAddedEvent(chatId, cached)); + } + } + + final cached = CachedChat.fromDbRow(rows.first); + final isStaleLast = status != 'REMOVED' && + msgIdInt != null && + cached.lastMsgId == msgIdInt && + status != 'EDITED'; + if (isStaleLast) { + _bump(); + return; + } + + final newRow = Map.from(rows.first); + if (status != 'REMOVED') { + if (msgIdInt != null) newRow['last_msg_id'] = msgIdInt; + if (msgTime != null) { + newRow['last_msg_time'] = msgTime; + if (status != 'EDITED') { + newRow['last_event_time'] = msgTime; + } + } + newRow['last_msg_text'] = msgText; + if (senderId != null) newRow['last_msg_sender'] = senderId; + } + if (unread != null) newRow['unread_count'] = unread; + + await AppDatabase.saveChats([newRow]); + _bump(); + } + + static Future _reconcileLastMessage( + int accountId, + int chatId, + Map chatRow, { + int? unread, + }) async { + final latest = await AppDatabase.loadMessages(accountId, chatId, limit: 1); + final newRow = Map.from(chatRow); + if (latest.isNotEmpty) { + final m = latest.first; + newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? ''); + newRow['last_msg_text'] = m['text']; + newRow['last_msg_time'] = m['time']; + newRow['last_msg_sender'] = m['sender_id']; + } else { + newRow['last_msg_id'] = null; + newRow['last_msg_text'] = lastMsgPlaceholder; + newRow['last_msg_sender'] = null; + } + if (unread != null) newRow['unread_count'] = unread; + await AppDatabase.saveChats([newRow]); + } + + /// Вызывается после успешного фетча истории чата — + /// если в превью был placeholder, заменяем его на актуальное + /// последнее сообщение из кеша. + static Future reconcileLastMessageIfPlaceholder( + int accountId, + int chatId, + ) async { + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final chat = CachedChat.fromDbRow(rows.first); + if (chat.lastMsgText != lastMsgPlaceholder) return; + await _reconcileLastMessage(accountId, chatId, rows.first); + _bump(); + } + + static Future _handleNotifMsgReactionsChanged(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final messageId = payload['messageId']?.toString(); + if (messageId == null || messageId.isEmpty) return; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final existing = await AppDatabase.loadMessage(accountId, chatId, messageId); + if (existing == null) return; + + Map payloadMap; + final raw = existing['payload']; + if (raw is String && raw.isNotEmpty) { + try { + payloadMap = Map.from(jsonDecode(raw) as Map); + } catch (_) { + payloadMap = {}; + } + } else { + payloadMap = {}; + } + + final counters = payload['counters']; + final totalCount = payload['totalCount']; + final reactionInfo = {}; + final prev = payloadMap['reactionInfo']; + if (prev is Map && prev['yourReaction'] != null) { + reactionInfo['yourReaction'] = prev['yourReaction']; + } + if (counters is List) reactionInfo['counters'] = counters; + if (totalCount is int) reactionInfo['totalCount'] = totalCount; + if (reactionInfo['counters'] == null || (counters is List && counters.isEmpty)) { + payloadMap.remove('reactionInfo'); + } else { + payloadMap['reactionInfo'] = reactionInfo; + } + + final newRow = Map.from(existing); + newRow['payload'] = jsonEncode(payloadMap); + await AppDatabase.saveMessages([newRow]); + final emitted = payloadMap['reactionInfo'] as Map?; + _messageEventsController.add( + MessageReactionsChangedEvent(chatId, messageId, emitted), + ); + _bump(); + } + static Future _handleNotifMark(Packet packet) async { final payload = packet.payload; if (payload is! Map) return; @@ -221,12 +512,15 @@ class ChatsModule { if (accountId == null) return; final dialogRows = await AppDatabase.loadDialogChats(accountId); - final byParticipant = >>{}; + final byParticipant = + row, CachedChat cached})>>{}; for (final row in dialogRows) { final cached = CachedChat.fromDbRow(row); for (final pid in cached.participants.keys) { if (pid == accountId) continue; - byParticipant.putIfAbsent(pid, () => []).add(row); + byParticipant + .putIfAbsent(pid, () => []) + .add((row: row, cached: cached)); } } @@ -238,8 +532,9 @@ class ChatsModule { final options = ContactCache.getOptions(contactId) ?? const {}; final affected = byParticipant[contactId]; if (affected == null) continue; - for (final row in affected) { - final cached = CachedChat.fromDbRow(row); + for (final entry in affected) { + final row = entry.row; + final cached = entry.cached; final sameTitle = cached.title == name; final sameAvatar = (cached.iconUrl ?? '') == (avatar ?? ''); final sameOptions = cached.options.length == options.length && @@ -288,6 +583,7 @@ class ChatsModule { logger.w('cacheServerChat: parse returned null for chat=${chat['id']}'); return null; } + _knownChats.add(parsed.id); final ex = existing[parsed.id]; if (ex != null && _sameContent(ex, parsed)) { return parsed; @@ -383,7 +679,11 @@ class ChatsModule { static Future> getChats(int accountId) async { try { final rows = await AppDatabase.loadChats(accountId); - return rows.map(CachedChat.fromDbRow).toList(); + final chats = rows.map(CachedChat.fromDbRow).toList(); + for (final c in chats) { + _knownChats.add(c.id); + } + return chats; } catch (e) { logger.e("Ошибка при получении чатов: $e"); return []; diff --git a/lib/backend/modules/file_uploader.dart b/lib/backend/modules/file_uploader.dart index 2aae308..3091e60 100644 --- a/lib/backend/modules/file_uploader.dart +++ b/lib/backend/modules/file_uploader.dart @@ -190,13 +190,23 @@ class FileUploader { Socket? socket; try { socket = await _openSocket(uri); + final boundary = '----KometBoundary${DateTime.now().microsecondsSinceEpoch}'; + final preamble = utf8.encode( + '--$boundary\r\n' + 'Content-Disposition: form-data; name="file"; filename="$filename"\r\n' + 'Content-Type: ${_contentTypeForFilename(filename)}\r\n' + '\r\n', + ); + final epilogue = utf8.encode('\r\n--$boundary--\r\n'); _writeImageHeaders( socket, uri, - bytes.length, - contentType: _contentTypeForFilename(filename), + preamble.length + bytes.length + epilogue.length, + boundary: boundary, ); + socket.add(preamble); socket.add(bytes); + socket.add(epilogue); await socket.flush(); final response = await _readFullResponse( @@ -208,7 +218,6 @@ class FileUploader { } catch (_) {} if (response == null) { - logger.w('uploadImage: empty/timed-out response'); return null; } final (status, body) = response; @@ -230,12 +239,12 @@ class FileUploader { } } - void _writeImageHeaders(Socket socket, Uri uri, int total, {required String contentType}) { + void _writeImageHeaders(Socket socket, Uri uri, int total, {required String boundary}) { final path = '${uri.path}${uri.hasQuery ? "?${uri.query}" : ""}'; final headers = StringBuffer() ..write('POST $path HTTP/1.1\r\n') ..write('Host: ${uri.host}\r\n') - ..write('Content-Type: $contentType\r\n') + ..write('Content-Type: multipart/form-data; boundary=$boundary\r\n') ..write('Content-Length: $total\r\n') ..write('Connection: keep-alive\r\n') ..write('User-Agent: ${Uri.encodeComponent('OKMessages/26.14.1 (Android 11; TECNO MOBILE LIMITED TECNO LE7n; xxhdpi 480dpi 1080x2208)')}\r\n') @@ -273,36 +282,63 @@ class FileUploader { Timer? timer; StreamSubscription>? sub; - void finish() { + void finishWith((int, String)? value) { timer?.cancel(); sub?.cancel(); - if (completer.isCompleted) return; + if (!completer.isCompleted) completer.complete(value); + } + + (int, String)? tryParse({required bool atClose}) { final headerEnd = _findHeaderEnd(bytes); - if (headerEnd == -1) { - completer.complete(null); - return; - } + if (headerEnd == -1) return null; final headerStr = utf8.decode(bytes.sublist(0, headerEnd), allowMalformed: true); final lines = headerStr.split('\r\n'); final parts = lines.first.split(' '); final status = parts.length >= 2 ? (int.tryParse(parts[1]) ?? 0) : 0; - final chunked = lines.skip(1).any( + final headerLines = lines.skip(1); + final chunked = headerLines.any( (l) => l.toLowerCase().startsWith('transfer-encoding:') && l.toLowerCase().contains('chunked'), ); + int? contentLength; + for (final l in headerLines) { + if (l.toLowerCase().startsWith('content-length:')) { + contentLength = int.tryParse(l.split(':').last.trim()); + } + } final rawBody = utf8.decode(bytes.sublist(headerEnd), allowMalformed: true); - final body = chunked ? _decodeChunked(rawBody) : rawBody; - completer.complete((status, body)); + if (chunked) { + if (!atClose && !rawBody.contains('\r\n0\r\n')) return null; + return (status, _decodeChunked(rawBody)); + } + if (contentLength != null && !atClose && bytes.length - headerEnd < contentLength) { + return null; + } + return (status, rawBody); } - void fail() { - timer?.cancel(); - sub?.cancel(); - if (!completer.isCompleted) completer.complete(null); - } - - sub = socket.listen(bytes.addAll, onError: (_) => fail(), onDone: finish); - timer = Timer(timeout, fail); + sub = socket.listen( + (chunk) { + bytes.addAll(chunk); + final parsed = tryParse(atClose: false); + if (parsed != null) finishWith(parsed); + }, + onError: (e) { + logger.w('uploadImage: socket error after ${bytes.length} bytes: $e'); + finishWith(tryParse(atClose: true)); + }, + onDone: () { + final parsed = tryParse(atClose: true); + if (parsed == null) { + logger.w('uploadImage: connection closed without HTTP response (${bytes.length} bytes)'); + } + finishWith(parsed); + }, + ); + timer = Timer(timeout, () { + logger.w('uploadImage: response timeout after ${bytes.length} bytes'); + finishWith(tryParse(atClose: true)); + }); return completer.future; } diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart index b2856d8..afa4860 100644 --- a/lib/backend/modules/folders.dart +++ b/lib/backend/modules/folders.dart @@ -44,10 +44,13 @@ class FoldersModule { List? foldersOrder, ) { if (foldersOrder == null || foldersOrder.isEmpty) return; - final orderedIds = foldersOrder.map((id) => id.toString()).toList(); + final orderIndex = {}; + for (var i = 0; i < foldersOrder.length; i++) { + orderIndex.putIfAbsent(foldersOrder[i].toString(), () => i); + } folders.sort((a, b) { - final aIndex = orderedIds.indexOf(a.id); - final bIndex = orderedIds.indexOf(b.id); + final aIndex = orderIndex[a.id] ?? -1; + final bIndex = orderIndex[b.id] ?? -1; if (aIndex == -1 && bIndex == -1) return 0; if (aIndex == -1) return 1; if (bIndex == -1) return -1; diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index f74c9e0..6108dcb 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -245,6 +245,29 @@ class CachedMessage { 'status': status, 'payload': payload != null ? jsonEncode(payload) : null, }; + + static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) { + List? attachments; + final attaches = msg['attaches']; + if (attaches is List && attaches.isNotEmpty) { + attachments = attaches + .whereType() + .map((a) => + MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + return CachedMessage( + id: msg['id']?.toString() ?? '', + accountId: accountId, + chatId: chatId, + senderId: msg['sender'] as int? ?? 0, + text: msg['text'] as String?, + time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, + status: (msg['status'] as String?) ?? 'sent', + payload: Map.from(msg), + attachments: attachments, + ); + } } class MessagesModule { @@ -545,6 +568,43 @@ class MessagesModule { } } + /// Запрашивает у сервера ссылку на воспроизведение видео (opcode 83). + /// + /// Формат подтверждён дампом: запрос `{messageId, chatId, token, videoId}`, + /// ответ содержит `MP4_1080/MP4_720/...`, `HLS`, `DASH`, `EXTERNAL`. + /// Возвращает лучший доступный progressive-MP4 (или HLS как запасной). + Future getVideoUrl({ + required String messageId, + required int chatId, + required String token, + required int videoId, + }) async { + try { + final response = await _api.sendRequest(Opcode.videoPlay, { + 'messageId': int.tryParse(messageId) ?? 0, + 'chatId': chatId, + 'token': token, + 'videoId': videoId, + }); + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + const mp4Keys = ['MP4_1080', 'MP4_720', 'MP4_480', 'MP4_360', 'MP4_240']; + for (final key in mp4Keys) { + final url = data[key]; + if (url is String && url.isNotEmpty) return url; + } + final hls = data['HLS']; + if (hls is String && hls.isNotEmpty) return hls; + final external = data['EXTERNAL']; + if (external is String && external.isNotEmpty) return external; + return null; + } catch (_) { + return null; + } + } + Future downloadVideo(String baseUrl, String videoToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { @@ -565,23 +625,6 @@ class MessagesModule { } } - Future getVideoUrl(String baseUrl, String videoToken) async { - try { - final response = await _api.sendRequest(Opcode.fileDownload, { - 'url': baseUrl, - 'token': videoToken, - }); - - if (!response.isOk) return null; - final data = response.payload; - if (data is! Map) return null; - - return data['content'] as String?; - } catch (e) { - return null; - } - } - Future downloadFile(String baseUrl, String fileToken) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { @@ -602,18 +645,27 @@ class MessagesModule { } } - Future getFileUrl(String baseUrl, String fileToken) async { + /// Запрашивает у сервера временный CDN-URL для скачивания файла (opcode 88). + /// + /// Формат подтверждён дампом: запрос `{messageId, chatId, fileId}`, + /// ответ `{url: "https://fd.oneme.ru/getfile?..."}`. + Future getFileUrl({ + required String messageId, + required int chatId, + required int fileId, + }) async { try { final response = await _api.sendRequest(Opcode.fileDownload, { - 'url': baseUrl, - 'token': fileToken, + 'messageId': int.tryParse(messageId) ?? 0, + 'chatId': chatId, + 'fileId': fileId, }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; - return data['content'] as String?; + return data['url'] as String?; } catch (e) { return null; } diff --git a/lib/backend/modules/polls.dart b/lib/backend/modules/polls.dart new file mode 100644 index 0000000..b3f9172 --- /dev/null +++ b/lib/backend/modules/polls.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; + +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../models/poll.dart'; + +class PollsModule extends ChangeNotifier { + final Api _api; + + PollsModule(this._api); + + final Map _cache = {}; + final Set _inFlight = {}; + + Poll? get(int pollId) => _cache[pollId]; + + Future fetch( + int chatId, + String messageId, + int pollId, { + bool force = false, + }) async { + if (pollId == 0) return; + if (!force && (_cache.containsKey(pollId) || _inFlight.contains(pollId))) { + return; + } + _inFlight.add(pollId); + try { + final mid = int.tryParse(messageId) ?? 0; + final response = await _api.sendRequest(Opcode.getPollUpdates, { + 'chatId': chatId, + 'polls': [ + {'messageId': mid, 'pollId': pollId}, + ], + }); + if (!response.isOk) return; + + final data = response.payload; + if (data is! Map) return; + + final polls = data['polls']; + if (polls is! List) return; + + var changed = false; + for (final p in polls) { + if (p is Map) { + final poll = Poll.fromServerMap(p); + if (poll.pollId != 0) { + _cache[poll.pollId] = poll; + changed = true; + } + } + } + if (changed) notifyListeners(); + } catch (_) { + // тихо игнорируем — опрос просто не отобразится + } finally { + _inFlight.remove(pollId); + } + } +} diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart new file mode 100644 index 0000000..8fdc292 --- /dev/null +++ b/lib/core/cache/info_cache.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import '../../backend/api.dart'; +import '../protocol/opcode_map.dart'; + +Api? _api; + +void attachInfoCacheApi(Api api) { + _api = api; +} + +class _Entry { + T? value; + DateTime? fetchedAt; + DateTime? failedAt; + Future? inFlight; +} + +class InfoCache { + final Duration ttl; + final Duration failureBackoff; + final Future Function(int id) fetcher; + final Map> _entries = {}; + + InfoCache({ + required this.ttl, + required this.fetcher, + this.failureBackoff = const Duration(seconds: 10), + }); + + bool _isFresh(_Entry e) { + if (e.fetchedAt == null) return false; + return DateTime.now().difference(e.fetchedAt!) < ttl; + } + + bool _isInFailureBackoff(_Entry e) { + if (e.failedAt == null) return false; + return DateTime.now().difference(e.failedAt!) < failureBackoff; + } + + Future get(int id, {bool forceRefresh = false}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + + if (!forceRefresh && _isFresh(entry)) { + return Future.value(entry.value); + } + if (!forceRefresh && _isInFailureBackoff(entry)) { + return Future.value(null); + } + if (entry.inFlight != null) return entry.inFlight!; + + final future = _runFetch(entry, id); + entry.inFlight = future; + return future; + } + + Future _runFetch(_Entry entry, int id) async { + try { + final result = await fetcher(id); + entry.value = result; + entry.fetchedAt = DateTime.now(); + entry.failedAt = null; + return result; + } catch (_) { + entry.failedAt = DateTime.now(); + return null; + } finally { + entry.inFlight = null; + } + } + + T? peek(int id) { + final entry = _entries[id]; + if (entry == null || !_isFresh(entry)) return null; + return entry.value; + } + + void invalidate(int id) => _entries.remove(id); + void clear() => _entries.clear(); + + void putValue(int id, T value, {DateTime? at}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + entry.value = value; + entry.fetchedAt = at ?? DateTime.now(); + entry.failedAt = null; + } + + void markFailed(int id, {DateTime? at}) { + final entry = _entries.putIfAbsent(id, () => _Entry()); + entry.failedAt = at ?? DateTime.now(); + } +} + +class ContactInfoFetch { + static final _cache = InfoCache>( + ttl: const Duration(minutes: 5), + fetcher: _fetch, + ); + + static Future?> get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static Map? peek(int id) => _cache.peek(id); + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static Future?> _fetch(int id) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.contactInfo, { + 'contactIds': [id], + }); + final data = resp.payload; + if (data is! Map) return null; + final contacts = data['contacts']; + if (contacts is! List || contacts.isEmpty) return null; + final first = contacts.first; + if (first is! Map) return null; + return Map.from(first); + } +} + +class PresenceFetch { + static final _cache = InfoCache>( + ttl: const Duration(seconds: 60), + fetcher: _fetch, + ); + + static Future?> get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static Map? peek(int id) => _cache.peek(id); + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static Future?> _fetch(int id) async { + final results = await _fetchBatch([id]); + return results[id]; + } + + static Future>> getMany( + List ids, { + bool forceRefresh = false, + }) async { + final result = >{}; + final missing = []; + for (final id in ids) { + if (!forceRefresh) { + final cached = _cache.peek(id); + if (cached != null) { + result[id] = cached; + continue; + } + } + missing.add(id); + } + if (missing.isNotEmpty) { + final fetched = await _fetchBatch(missing); + final now = DateTime.now(); + for (final id in missing) { + final value = fetched[id]; + if (value != null) { + _cache.putValue(id, value, at: now); + result[id] = value; + } else { + _cache.markFailed(id, at: now); + } + } + } + return result; + } + + static Future>> _fetchBatch(List ids) async { + final api = _api; + if (api == null || api.state != SessionState.online || ids.isEmpty) { + return const {}; + } + final resp = await api.sendRequest(Opcode.contactPresence, { + 'contactIds': ids, + }); + final data = resp.payload; + if (data is! Map) return const {}; + final presence = data['presence']; + if (presence is! Map) return const {}; + final out = >{}; + for (final id in ids) { + final entry = presence[id.toString()] ?? presence[id]; + if (entry is Map) { + out[id] = Map.from(entry); + } + } + return out; + } +} + +class ChatInfoFetch { + static final _cache = InfoCache>( + ttl: const Duration(minutes: 5), + fetcher: _fetch, + ); + + static Future?> get(int id, {bool forceRefresh = false}) => + _cache.get(id, forceRefresh: forceRefresh); + + static Map? peek(int id) => _cache.peek(id); + + static void invalidate(int id) => _cache.invalidate(id); + static void clear() => _cache.clear(); + + static Future?> _fetch(int id) async { + final api = _api; + if (api == null || api.state != SessionState.online) return null; + final resp = await api.sendRequest(Opcode.chatInfo, { + 'chatIds': [id], + }); + final data = resp.payload; + if (data is! Map) return null; + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return null; + final first = chats.first; + if (first is! Map) return null; + return Map.from(first); + } +} diff --git a/lib/core/config/app_fonts.dart b/lib/core/config/app_fonts.dart index 74d3d59..f99d1f9 100644 --- a/lib/core/config/app_fonts.dart +++ b/lib/core/config/app_fonts.dart @@ -23,7 +23,7 @@ class AppFonts { static const String customPrefKey = 'app_custom_fonts'; static const String customPrefix = 'g:'; - static const double minScale = 0.85; + static const double minScale = 0.60; static const double maxScale = 1.35; static const double defaultScale = 1.0; diff --git a/lib/core/config/app_media_cache.dart b/lib/core/config/app_media_cache.dart new file mode 100644 index 0000000..8ff5ee9 --- /dev/null +++ b/lib/core/config/app_media_cache.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppMediaCacheLimit { + static const prefKey = 'media_cache_limit_bytes'; + static const int defaultValue = 500 * 1024 * 1024; // 500 МБ + + /// Значение «без лимита» — вытеснение из кэша отключено. + static const int unlimited = 0; + + /// Доступные пресеты лимита, байты (0 — без лимита). + static const List presets = [ + 100 * 1024 * 1024, + 250 * 1024 * 1024, + 500 * 1024 * 1024, + 1024 * 1024 * 1024, + 2 * 1024 * 1024 * 1024, + unlimited, + ]; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(prefKey) ?? defaultValue; + } + + static Future save(int value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(prefKey, value); + } +} diff --git a/lib/core/config/app_pranks.dart b/lib/core/config/app_pranks.dart new file mode 100644 index 0000000..033a23a --- /dev/null +++ b/lib/core/config/app_pranks.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppPranks { + static const prefKey = 'dev_pranks'; + static const bool defaultValue = false; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? defaultValue; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/core/config/app_stories.dart b/lib/core/config/app_stories.dart new file mode 100644 index 0000000..4f75a2d --- /dev/null +++ b/lib/core/config/app_stories.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppStories { + static const prefKey = 'dev_stories'; + static const bool defaultValue = false; + + static final ValueNotifier current = ValueNotifier(defaultValue); + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? defaultValue; + } + + static Future save(bool value) async { + current.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } +} diff --git a/lib/core/protocol/chat_cache_fingerprint.dart b/lib/core/protocol/chat_cache_fingerprint.dart new file mode 100644 index 0000000..b2ab84a --- /dev/null +++ b/lib/core/protocol/chat_cache_fingerprint.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +class ChatCacheFingerprint { + static final Uint8List _signatureDigest = _hex( + '1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93', + ); + static final Uint8List _soDigest = _hex( + 'c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111', + ); + static final Uint8List _dexDigest = _hex( + '490a2746c7ebbff050353c575a186ca65bc708f9b6e0c1329b59a3bfab6c3924', + ); + + static Uint8List compute(int callsSeed, String deviceId) { + final seed = _int64BigEndian(callsSeed); + final device = Uint8List.fromList(utf8.encode(deviceId)); + final result = BytesBuilder(); + result.add(_sha256(_signatureDigest, seed, device)); + result.add(_sha256(_soDigest, seed, device)); + result.add(_sha256(_dexDigest, seed, device)); + return result.toBytes(); + } + + static List _sha256(Uint8List a, Uint8List b, Uint8List c) { + final builder = BytesBuilder() + ..add(a) + ..add(b) + ..add(c); + return sha256.convert(builder.toBytes()).bytes; + } + + static Uint8List _int64BigEndian(int value) { + final data = ByteData(8)..setInt64(0, value, Endian.big); + return data.buffer.asUint8List(); + } + + static Uint8List _hex(String hex) { + final out = Uint8List(hex.length ~/ 2); + for (var i = 0; i < out.length; i++) { + out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; + } +} diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 75af3a2..448a866 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -82,19 +82,41 @@ String messageFromErrorPayload(dynamic payload) { return s.isNotEmpty ? s : 'Неизвестная ошибка'; } -/// Упаковка пакета для отправки на сервер +/// Payload меньше этого размера отправляется без сжатия (как в оригинале). +const int _compressionThreshold = 32; + +/// Упаковка пакета для отправки на сервер. +/// +/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold] +/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия: +/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по +/// которому получатель выделяет буфер под распаковку). Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { - final header = ByteData(headerSize); + final Uint8List raw = msgpack.serialize(payload); + + final List body; + final int flag; + if (raw.length < _compressionThreshold) { + body = raw; + flag = 0; + } else { + body = lz4Compress(raw); + flag = (raw.length ~/ body.length) + 1; + } + + final out = Uint8List(headerSize + body.length); + final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize); header.setUint8(0, 10); header.setUint8(1, CmdType.request); header.setUint16(2, seq, Endian.big); header.setUint16(4, opcode, Endian.big); - - final payloadBytes = msgpack.serialize(payload); - final payloadLen = payloadBytes.length & 0xFFFFFF; - header.setUint32(6, payloadLen, Endian.big); - - return Uint8List.fromList(header.buffer.asUint8List() + payloadBytes); + header.setUint32( + 6, + ((flag & 0xFF) << 24) | (body.length & 0xFFFFFF), + Endian.big, + ); + out.setRange(headerSize, out.length, body); + return out; } /// Распаковка пакета от сервера diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 8a1d41b..f1cccfa 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -435,17 +435,20 @@ class AppDatabase { // Chats cache static Future saveChats(List> rows) async { + if (rows.isEmpty) return; try { final db = await _instance; - final batch = db.batch(); - for (final row in rows) { - batch.insert( - 'chats_cache', - row, - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - await batch.commit(noResult: true); + await db.transaction((txn) async { + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + 'chats_cache', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); } catch (e) { logger.e("Ошибка при сохранении чата: $e"); } @@ -587,4 +590,33 @@ class AppDatabase { whereArgs: [accountId, chatId], ); } + + static Future?> loadMessage( + int accountId, + int chatId, + String messageId, + ) async { + final db = await _instance; + final rows = await db.query( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first; + } + + static Future deleteMessage( + int accountId, + int chatId, + String messageId, + ) async { + final db = await _instance; + await db.delete( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id = ?', + whereArgs: [accountId, chatId, messageId], + ); + } } diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index 548f5ad..b5e8c57 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -1,8 +1,8 @@ import 'package:shared_preferences/shared_preferences.dart'; class SpoofingService { - static const String hardcodedAppVersion = '26.14.1'; - static const int hardcodedBuildNumber = 6606; + static const String hardcodedAppVersion = '26.17.1'; + static const int hardcodedBuildNumber = 6712; static Future?> getSpoofedSessionData() async { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index b04ed77..2d4f600 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -24,6 +24,11 @@ class TokenStorage { await prefs.setString(_activeAccountKey, accountId.toString()); } + static Future clearActiveAccount() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_activeAccountKey); + } + static Future getActiveAccountId() async { final prefs = await SharedPreferences.getInstance(); final val = prefs.getString(_activeAccountKey); diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index e0a45ee..7321133 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -7,46 +7,73 @@ import '../utils/logger.dart'; /// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов. class PacketReceiver { Uint8List _buffer = Uint8List(0); + int _start = 0; + int _end = 0; static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта /// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы. /// Полностью синхронный — нарезка не блокируется на распаковке, поэтому /// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`. + /// + /// Накопление идёт без перекопирования всего буфера на каждый чанк: целые + /// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается + /// сдвигом указателя `_start`, а не пересборкой буфера. List feed(Uint8List data) { - final newBuffer = Uint8List(_buffer.length + data.length); - newBuffer.setAll(0, _buffer); - newBuffer.setAll(_buffer.length, data); - _buffer = newBuffer; + _append(data); - if (_buffer.length > _maxBufferSize) { + if (_end - _start > _maxBufferSize) { logger.e( - 'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс', + 'PacketReceiver: переполнение буфера (${_end - _start} B), сброс', ); reset(); return const []; } final packets = []; - while (_buffer.length >= headerSize) { + while (_end - _start >= headerSize) { final bd = ByteData.view( _buffer.buffer, - _buffer.offsetInBytes, + _buffer.offsetInBytes + _start, headerSize, ); final packedLen = bd.getUint32(6, Endian.big); final payloadLength = packedLen & 0xFFFFFF; final totalLength = headerSize + payloadLength; - if (_buffer.length < totalLength) break; + if (_end - _start < totalLength) break; - packets.add(Uint8List.sublistView(_buffer, 0, totalLength)); - _buffer = _buffer.sublist(totalLength); + packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength)); + _start += totalLength; + } + + if (_start == _end) { + _start = 0; + _end = 0; } return packets; } + void _append(Uint8List data) { + final pending = _end - _start; + if (pending == 0) { + _buffer = Uint8List.fromList(data); + _start = 0; + _end = data.length; + return; + } + final total = pending + data.length; + final newBuffer = Uint8List(total); + newBuffer.setRange(0, pending, _buffer, _start); + newBuffer.setRange(pending, total, data); + _buffer = newBuffer; + _start = 0; + _end = total; + } + void reset() { _buffer = Uint8List(0); + _start = 0; + _end = 0; } } diff --git a/lib/core/utils/download_progress.dart b/lib/core/utils/download_progress.dart new file mode 100644 index 0000000..5af4219 --- /dev/null +++ b/lib/core/utils/download_progress.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; + +/// Прогресс активных загрузок вложений, ключ — имя в кэше. +/// +/// Значение: `null` — не загружается; `0..1` — доля загруженного. +class MediaDownloadProgress { + static final Map> _notifiers = {}; + + static ValueNotifier notifier(String key) => + _notifiers.putIfAbsent(key, () => ValueNotifier(null)); + + static void set(String key, double? value) { + notifier(key).value = value; + } +} diff --git a/lib/core/utils/file_download.dart b/lib/core/utils/file_download.dart new file mode 100644 index 0000000..a9fe696 --- /dev/null +++ b/lib/core/utils/file_download.dart @@ -0,0 +1,50 @@ +import 'package:open_filex/open_filex.dart'; + +import 'media_cache.dart'; + +class FileDownloadResult { + final bool ok; + final String? path; + final String? error; + + const FileDownloadResult({required this.ok, this.path, this.error}); +} + +/// Открывает файл из кэша, скачивая его при отсутствии. +/// +/// [cacheName] — стабильное имя в кэше (например, `_имя.ext`). +/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше, +/// чтобы не дёргать сервер за временной ссылкой повторно. +Future openCachedFile( + String cacheName, + Future Function() resolveUrl, { + void Function(double progress)? onProgress, +}) async { + try { + var file = await MediaCache.existing(cacheName); + + if (file == null) { + final url = await resolveUrl(); + if (url == null || url.isEmpty) { + return const FileDownloadResult(ok: false, error: 'нет ссылки'); + } + file = await MediaCache.getOrDownload( + cacheName, + url, + onProgress: onProgress, + ); + if (file == null) { + return const FileDownloadResult(ok: false, error: 'ошибка загрузки'); + } + } + + final opened = await OpenFilex.open(file.path); + return FileDownloadResult( + ok: opened.type == ResultType.done, + path: file.path, + error: opened.type == ResultType.done ? null : opened.message, + ); + } catch (e) { + return FileDownloadResult(ok: false, error: e.toString()); + } +} diff --git a/lib/core/utils/image_utils.dart b/lib/core/utils/image_utils.dart new file mode 100644 index 0000000..a7b99a7 --- /dev/null +++ b/lib/core/utils/image_utils.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +const int _avatarMaxDimension = 1024; +const int _avatarTargetBytes = 900 * 1024; + +Future compressAvatar(Uint8List input) => compute(_encodeAvatar, input); + +Uint8List? _encodeAvatar(Uint8List input) { + final decoded = img.decodeImage(input); + if (decoded == null) return null; + final oriented = img.bakeOrientation(decoded); + final image = oriented.width > _avatarMaxDimension || oriented.height > _avatarMaxDimension + ? img.copyResize( + oriented, + width: oriented.width >= oriented.height ? _avatarMaxDimension : null, + height: oriented.height > oriented.width ? _avatarMaxDimension : null, + interpolation: img.Interpolation.average, + ) + : oriented; + var quality = 88; + var out = img.encodeJpg(image, quality: quality); + while (out.lengthInBytes > _avatarTargetBytes && quality > 35) { + quality -= 12; + out = img.encodeJpg(image, quality: quality); + } + return out; +} diff --git a/lib/core/utils/media_cache.dart b/lib/core/utils/media_cache.dart new file mode 100644 index 0000000..cc274ac --- /dev/null +++ b/lib/core/utils/media_cache.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../config/app_media_cache.dart'; + +/// Постоянный дисковый кэш скачанных медиа (файлы, видео). +/// +/// Хранит файлы в `/media_cache/` под детерминированным именем +/// (обычно по id вложения), чтобы повторные открытия не качали заново. +class MediaCache { + /// Максимальный размер кэша (настраивается в дев-меню); при превышении + /// вытесняются старые файлы (LRU). + static int get maxBytes => AppMediaCacheLimit.current.value; + + static Directory? _dir; + static int? _cachedSize; + + static Future _cacheDir() async { + final cached = _dir; + if (cached != null) return cached; + final base = await getApplicationSupportDirectory(); + final dir = Directory(p.join(base.path, 'media_cache')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + _dir = dir; + return dir; + } + + /// Путь к кэш-файлу с именем [name] (файл может ещё не существовать). + static Future fileFor(String name) async { + final dir = await _cacheDir(); + return File(p.join(dir.path, _sanitize(name))); + } + + /// Существует ли непустой кэш-файл [name]. + /// + /// При попадании обновляет mtime файла — это делает вытеснение LRU + /// (часто используемые файлы переживают очистку). + static Future existing(String name) async { + final file = await fileFor(name); + if (await file.exists() && await file.length() > 0) { + try { + await file.setLastModified(DateTime.now()); + } catch (_) {} + return file; + } + return null; + } + + /// Возвращает кэш-файл [name], скачивая [url] при отсутствии. + /// + /// Загрузка идёт во временный `.part` и переименовывается атомарно — + /// прерванная закачка не считается валидным кэшем. + static Future getOrDownload( + String name, + String url, { + void Function(double progress)? onProgress, + }) async { + final existingFile = await existing(name); + if (existingFile != null) return existingFile; + + final file = await fileFor(name); + final part = File('${file.path}.part'); + final client = HttpClient(); + try { + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + if (response.statusCode != 200) return null; + + final total = response.contentLength; + var received = 0; + final sink = part.openWrite(); + await for (final chunk in response) { + received += chunk.length; + sink.add(chunk); + if (onProgress != null && total > 0) { + onProgress(received / total); + } + } + await sink.close(); + await part.rename(file.path); + final known = _cachedSize; + if (known != null) { + try { + _cachedSize = known + await file.length(); + } catch (_) {} + } + await _enforceLimit(); + return file; + } catch (_) { + if (await part.exists()) { + try { + await part.delete(); + } catch (_) {} + } + return null; + } finally { + client.close(); + } + } + + /// Суммарный размер кэша в байтах. + /// + /// Результат держится в памяти и поддерживается инкрементально при + /// загрузке/очистке/вытеснении — повторные вызовы не пересканируют каталог. + static Future currentSize() async { + final cached = _cachedSize; + if (cached != null) return cached; + final total = await _scanSize(); + _cachedSize = total; + return total; + } + + static Future _scanSize() async { + final dir = await _cacheDir(); + var total = 0; + await for (final entity in dir.list()) { + if (entity is File) { + try { + total += await entity.length(); + } catch (_) {} + } + } + return total; + } + + /// Полностью очищает кэш. Возвращает число удалённых байт. + static Future clear() async { + final dir = await _cacheDir(); + var freed = 0; + await for (final entity in dir.list()) { + if (entity is File) { + try { + freed += await entity.length(); + await entity.delete(); + } catch (_) {} + } + } + _cachedSize = 0; + return freed; + } + + /// Вытесняет старые файлы (по mtime), пока размер превышает [maxBytes]. + /// + /// Под лимитом — ранний выход без сканирования каталога (частый случай). + /// Каталог обходится только когда лимит реально превышен. + static Future _enforceLimit() async { + final limit = maxBytes; + if (limit <= 0) return; + + var total = _cachedSize ?? await _scanSize(); + if (total <= limit) { + _cachedSize = total; + return; + } + + final dir = await _cacheDir(); + final files = []; + await for (final entity in dir.list()) { + if (entity is File && !entity.path.endsWith('.part')) { + files.add(entity); + } + } + + files.sort((a, b) => + a.statSync().modified.compareTo(b.statSync().modified)); + + for (final file in files) { + if (total <= limit) break; + try { + total -= await file.length(); + await file.delete(); + } catch (_) {} + } + _cachedSize = total; + } + + static String _sanitize(String name) { + final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); + return cleaned.isEmpty ? 'file' : cleaned; + } +} diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 5a52c01..5d09d5f 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -4,6 +4,7 @@ import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'password_2fa_screen.dart'; +import 'registration_screen.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; @@ -167,6 +168,20 @@ class _CodeConfirmationScreenState extends State return; } + if (result.isRegistration) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => RegistrationScreen( + phoneNumber: widget.phoneNumber, + registerToken: result.registerToken!, + presetAvatars: result.presetAvatars, + ), + ), + ); + return; + } + final loginResult = await accountModule.login(); if (!mounted) return; @@ -183,10 +198,8 @@ class _CodeConfirmationScreenState extends State PageRouteBuilder( transitionDuration: const Duration(milliseconds: 240), pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), - transitionsBuilder: (_, animation, __, child) => FadeTransition( - opacity: animation, - child: child, - ), + transitionsBuilder: (_, animation, __, child) => + FadeTransition(opacity: animation, child: child), ), (route) => false, ); diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 037a5c8..40fb36b 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -13,11 +13,16 @@ import 'select_country_screen.dart'; import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; import '../profile/spoof_screen.dart'; +import '../profile/debug_menu_screen.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/adaptive_shell.dart'; +import '../../../backend/api.dart'; import '../../../main.dart'; class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); + final int? returnToAccountId; + + const LoginScreen({super.key, this.returnToAccountId}); @override State createState() => _LoginScreenState(); @@ -30,15 +35,36 @@ class _LoginScreenState extends State { bool _isTOSRead = false; String? _phoneError; Timer? _phoneErrorTimer; + int _logoTapCount = 0; + Timer? _logoTapTimer; @override void initState() { super.initState(); + if (api.state == SessionState.disconnected) { + unawaited(api.connect()); + } _selectedCountry = countriesByCode['RU'] ?? allCountries.first; _clampCountryToAllowed(); _checkTOS(); } + Future _onBackPressed() async { + final returnId = widget.returnToAccountId; + if (returnId != null) { + try { + await accountModule.switchAccount(returnId); + } catch (_) {} + if (!mounted) return; + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const AdaptiveShell()), + (route) => false, + ); + return; + } + if (Navigator.canPop(context)) Navigator.pop(context); + } + void _clampCountryToAllowed() { final allowed = api.registrationCountries; if (allowed.any((c) => c.code == _selectedCountry.code)) return; @@ -51,6 +77,7 @@ class _LoginScreenState extends State { @override void dispose() { _phoneErrorTimer?.cancel(); + _logoTapTimer?.cancel(); _phoneController.dispose(); super.dispose(); } @@ -64,6 +91,22 @@ class _LoginScreenState extends State { } } + void _onLogoTap() { + _logoTapTimer?.cancel(); + _logoTapTimer = Timer(const Duration(milliseconds: 600), () { + _logoTapCount = 0; + }); + _logoTapCount++; + if (_logoTapCount >= 7) { + _logoTapTimer?.cancel(); + _logoTapCount = 0; + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const DebugMenuScreen()), + ); + } + } + Future _markTOSRead() async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('IsReadeTOS', true); @@ -666,9 +709,10 @@ class _LoginScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (Navigator.canPop(context)) + if (Navigator.canPop(context) || + widget.returnToAccountId != null) IconButton( - onPressed: () => Navigator.pop(context), + onPressed: _onBackPressed, icon: Icon( Symbols.arrow_back, color: cs.onSurfaceVariant, @@ -704,10 +748,14 @@ class _LoginScreenState extends State { Center( child: Column( children: [ - Image.asset( - 'assets/komet.png', - height: 80, - color: cs.onSurface, + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onLogoTap, + child: Image.asset( + 'assets/komet.png', + height: 80, + color: cs.onSurface, + ), ), const SizedBox(height: 16), Text( diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index a6716b0..f64b970 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -56,10 +56,8 @@ class _Password2FAScreenState extends State { PageRouteBuilder( transitionDuration: const Duration(milliseconds: 240), pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), - transitionsBuilder: (_, animation, __, child) => FadeTransition( - opacity: animation, - child: child, - ), + transitionsBuilder: (_, animation, __, child) => + FadeTransition(opacity: animation, child: child), ), (route) => false, ); diff --git a/lib/frontend/screens/auth/registration_screen.dart b/lib/frontend/screens/auth/registration_screen.dart new file mode 100644 index 0000000..6347c3b --- /dev/null +++ b/lib/frontend/screens/auth/registration_screen.dart @@ -0,0 +1,327 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +import '../../../backend/modules/account.dart'; +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/login_success_screen.dart'; + +class RegistrationScreen extends StatefulWidget { + final String phoneNumber; + final String registerToken; + final List presetAvatars; + + const RegistrationScreen({ + super.key, + required this.phoneNumber, + required this.registerToken, + required this.presetAvatars, + }); + + @override + State createState() => _RegistrationScreenState(); +} + +class _RegistrationScreenState extends State { + final TextEditingController _firstNameController = TextEditingController(); + final TextEditingController _lastNameController = TextEditingController(); + + int? _selectedPhotoId; + String? _selectedAvatarUrl; + bool _isSubmitting = false; + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + super.dispose(); + } + + bool get _canSubmit => + !_isSubmitting && _firstNameController.text.trim().isNotEmpty; + + Future _submit() async { + final firstName = _firstNameController.text.trim(); + if (firstName.isEmpty) return; + final lastName = _lastNameController.text.trim(); + + setState(() => _isSubmitting = true); + try { + final accountId = await accountModule.completeRegistration( + token: widget.registerToken, + firstName: firstName, + lastName: lastName.isEmpty ? null : lastName, + photoId: _selectedPhotoId, + ); + + final loginResult = await accountModule.login( + accountId: accountId, + token: '', + ); + + if (!mounted) return; + + final avatar = await precacheLoginAvatar( + context, + loginResult.profile.baseUrl, + ); + + if (!mounted) return; + + Navigator.pushAndRemoveUntil( + context, + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 240), + pageBuilder: (_, __, ___) => LoginSuccessScreen(avatar: avatar), + transitionsBuilder: (_, animation, __, child) => + FadeTransition(opacity: animation, child: child), + ), + (route) => false, + ); + } catch (e) { + if (!mounted) return; + setState(() => _isSubmitting = false); + showCustomNotification(context, e.toString()); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final firstName = _firstNameController.text.trim(); + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: cs.onSurfaceVariant), + onPressed: _isSubmitting ? null : () => Navigator.pop(context), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _canSubmit ? _submit : null, + backgroundColor: _canSubmit + ? cs.primaryContainer + : cs.surfaceContainerHighest, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + child: _isSubmitting + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: cs.onSurfaceVariant, + ), + ) + : Icon( + Icons.arrow_forward, + color: _canSubmit + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 96), + children: [ + Text( + l10n.registrationTitle, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 26, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + l10n.registrationSubtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 1.4, + ), + ), + const SizedBox(height: 28), + Center( + child: Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.5), + width: 2.5, + ), + ), + child: ClipOval( + child: _selectedAvatarUrl != null + ? CachedNetworkImage( + imageUrl: _selectedAvatarUrl!, + fit: BoxFit.cover, + ) + : Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + firstName.isNotEmpty + ? firstName[0].toUpperCase() + : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 28), + _buildTextField( + cs, + label: l10n.editProfileFirstName, + controller: _firstNameController, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 14), + _buildTextField( + cs, + label: l10n.editProfileLastName, + controller: _lastNameController, + textInputAction: TextInputAction.done, + ), + if (widget.presetAvatars.isNotEmpty) ...[ + const SizedBox(height: 28), + Text( + l10n.registrationChooseAvatar, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + for (final category in widget.presetAvatars) + _buildAvatarCategory(cs, category), + ], + ], + ), + ), + ); + } + + Widget _buildTextField( + ColorScheme cs, { + required String label, + required TextEditingController controller, + required TextInputAction textInputAction, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 6), + child: Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ), + TextField( + controller: controller, + enabled: !_isSubmitting, + textInputAction: textInputAction, + onChanged: (_) => setState(() {}), + style: TextStyle(color: cs.onSurface, fontSize: 15), + decoration: InputDecoration( + filled: true, + fillColor: cs.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } + + Widget _buildAvatarCategory(ColorScheme cs, PresetAvatarCategory category) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + if (category.name.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 10), + child: Text( + category.name, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + height: 64, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: category.avatars.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final avatar = category.avatars[index]; + final selected = _selectedPhotoId == avatar.id; + return GestureDetector( + onTap: _isSubmitting + ? null + : () => setState(() { + _selectedPhotoId = avatar.id; + _selectedAvatarUrl = avatar.url; + }), + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected ? cs.primary : Colors.transparent, + width: 2.5, + ), + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: ClipOval( + child: CachedNetworkImage( + imageUrl: avatar.url, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: cs.surfaceContainerHigh, + ), + errorWidget: (_, __, ___) => Container( + color: cs.surfaceContainerHigh, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart new file mode 100644 index 0000000..4ca0beb --- /dev/null +++ b/lib/frontend/screens/calls/call_screen.dart @@ -0,0 +1,381 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +enum CallScreenState { incoming, outgoing, active } + +class CallScreen extends StatefulWidget { + final String name; + final String? avatarUrl; + final CallScreenState initialState; + + const CallScreen({ + super.key, + required this.name, + this.avatarUrl, + this.initialState = CallScreenState.incoming, + }); + + @override + State createState() => _CallScreenState(); +} + +class _CallScreenState extends State + with SingleTickerProviderStateMixin { + late CallScreenState _state; + Timer? _timer; + int _seconds = 0; + bool _isMuted = false; + bool _isSpeaker = false; + late AnimationController _pulseController; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + _state = widget.initialState; + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + _pulseAnimation = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + if (_state == CallScreenState.outgoing) { + _startOutgoingTimer(); + } + } + + void _startOutgoingTimer() { + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _seconds++); + if (_seconds >= 3 && _state == CallScreenState.outgoing) { + _timer?.cancel(); + setState(() => _state = CallScreenState.active); + _startActiveTimer(); + } + }); + } + + void _startActiveTimer() { + _seconds = 0; + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _seconds++); + }); + } + + String get _timerText { + final m = (_seconds ~/ 60).toString().padLeft(2, '0'); + final s = (_seconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + void _accept() { + setState(() { + _state = CallScreenState.active; + _seconds = 0; + }); + _startActiveTimer(); + } + + void _endCall() { + _timer?.cancel(); + Navigator.pop(context); + } + + @override + void dispose() { + _timer?.cancel(); + _pulseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenH = MediaQuery.of(context).size.height; + + return Scaffold( + backgroundColor: const Color(0xFF0E0E14), + body: SafeArea( + child: Column( + children: [ + const Spacer(flex: 3), + _buildAvatar(screenH), + const SizedBox(height: 24), + _buildName(), + const SizedBox(height: 8), + _buildStatus(), + const Spacer(flex: 2), + _buildActions(), + const SizedBox(height: 48), + ], + ), + ), + ); + } + + Widget _buildAvatar(double screenH) { + final size = screenH * 0.18; + final cs = Theme.of(context).colorScheme; + final isRinging = _state == CallScreenState.incoming; + final isOutgoing = _state == CallScreenState.outgoing; + + return AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + final scale = (isRinging || isOutgoing) + ? _pulseAnimation.value + : 1.0; + return Transform.scale( + scale: scale, + child: child, + ); + }, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primaryContainer.withValues(alpha: 0.2), + border: Border.all( + color: cs.primary.withValues(alpha: 0.3), + width: 2, + ), + ), + child: ClipOval( + child: widget.avatarUrl != null && widget.avatarUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: widget.avatarUrl!, + fit: BoxFit.cover, + memCacheWidth: 360, + memCacheHeight: 360, + errorWidget: (_, _, _) => _fallbackAvatar(size), + ) + : _fallbackAvatar(size), + ), + ), + ); + } + + Widget _fallbackAvatar(double size) { + final cs = Theme.of(context).colorScheme; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primaryContainer, + ), + alignment: Alignment.center, + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: size * 0.4, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildName() { + final cs = Theme.of(context).colorScheme; + return Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 26, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ); + } + + Widget _buildStatus() { + final cs = Theme.of(context).colorScheme; + String text; + switch (_state) { + case CallScreenState.incoming: + text = 'Входящий звонок'; + case CallScreenState.outgoing: + text = 'Вызов...'; + case CallScreenState.active: + text = _timerText; + } + return Text( + text, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ); + } + + Widget _buildActions() { + switch (_state) { + case CallScreenState.incoming: + return _buildIncomingActions(); + case CallScreenState.outgoing: + return _buildOutgoingActions(); + case CallScreenState.active: + return _buildActiveActions(); + } + } + + Widget _buildIncomingActions() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _ActionButton( + icon: Symbols.phone_disabled, + label: 'Отклонить', + color: const Color(0xFFBA1A1A), + onTap: _endCall, + ), + const SizedBox(width: 48), + _ActionButton( + icon: Symbols.phone, + label: 'Принять', + color: const Color(0xFF3A691E), + onTap: _accept, + ), + ], + ); + } + + Widget _buildOutgoingActions() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _ActionButton( + icon: Symbols.phone_disabled, + label: 'Отмена', + color: const Color(0xFFBA1A1A), + onTap: _endCall, + ), + ], + ); + } + + Widget _buildActiveActions() { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _CircleActionButton( + icon: _isMuted ? Symbols.mic_off : Symbols.mic, + active: _isMuted, + onTap: () => setState(() => _isMuted = !_isMuted), + ), + const SizedBox(width: 32), + _CircleActionButton( + icon: _isMuted ? Symbols.volume_off : Symbols.volume_up, + active: _isSpeaker, + onTap: () => setState(() => _isSpeaker = !_isSpeaker), + ), + const SizedBox(width: 32), + _CircleActionButton( + icon: Symbols.bluetooth_audio, + active: false, + onTap: () {}, + ), + ], + ), + const SizedBox(height: 40), + _ActionButton( + icon: Symbols.phone_disabled, + label: 'Завершить', + color: const Color(0xFFBA1A1A), + onTap: _endCall, + ), + ], + ); + } +} + +class _ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + const _ActionButton({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + alignment: Alignment.center, + child: Icon(icon, color: Colors.white, size: 28, fill: 1), + ), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +class _CircleActionButton extends StatelessWidget { + final IconData icon; + final bool active; + final VoidCallback onTap; + + const _CircleActionButton({ + required this.icon, + required this.active, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active + ? Colors.white.withValues(alpha: 0.2) + : Colors.white.withValues(alpha: 0.1), + ), + alignment: Alignment.center, + child: Icon( + icon, + color: active ? Colors.white : Colors.white70, + size: 24, + fill: 1, + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index ef5a480..8965c7a 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -3,9 +3,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart' show ContactCache; -import '../../../core/protocol/opcode_map.dart'; +import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; -import '../../../main.dart' as main; class _MemberInfo { final int id; @@ -96,21 +95,13 @@ class _ChatInfoScreenState extends State { final profile = await AppDatabase.loadActiveProfile(); _myId = profile?.id ?? 0; - final packet = await main.api.sendRequest( - Opcode.chatInfo, - {'chatIds': [widget.chatId]}, - ); - if (!packet.isOk || !mounted) { - if (mounted) setState(() => _isLoading = false); + final info = await ChatInfoFetch.get(widget.chatId); + if (!mounted) return; + if (info == null) { + setState(() => _isLoading = false); return; } - - final chats = (packet.payload as Map?)?['chats'] as List?; - if (chats == null || chats.isEmpty) { - if (mounted) setState(() => _isLoading = false); - return; - } - _chatData = Map.from(chats.first as Map); + _chatData = info; if (widget.chatType == 'DIALOG') { final parts = _chatData!['participants'] as Map? ?? {}; @@ -123,32 +114,19 @@ class _ChatInfoScreenState extends State { } if (_otherId != null) { - final cp = await main.api.sendRequest( - Opcode.contactInfo, - {'contactIds': [_otherId]}, - ); - if (cp.isOk) { - final contacts = (cp.payload as Map?)?['contacts'] as List?; - if (contacts != null && contacts.isNotEmpty) { - _contactData = Map.from(contacts.first as Map); - final opts = _contactData!['options']; - _isBot = (opts is List) && opts.contains('BOT'); - } + final contact = await ContactInfoFetch.get(_otherId!); + if (contact != null) { + _contactData = contact; + final opts = _contactData!['options']; + _isBot = (opts is List) && opts.contains('BOT'); } - final pp = await main.api.sendRequest( - Opcode.contactPresence, - {'contactIds': [_otherId]}, - ); - if (pp.isOk) { - final presence = (pp.payload as Map?)?['presence'] as Map?; - final p = presence?[_otherId.toString()] ?? presence?[_otherId]; - if (p is Map) { - _seenTime = p['seen'] as int?; - final st = (p['status'] as int?) ?? 0; - _presenceStatus = st; - _isOnline = st == 1; - } + final presence = await PresenceFetch.get(_otherId!); + if (presence != null) { + _seenTime = presence['seen'] as int?; + final st = (presence['status'] as int?) ?? 0; + _presenceStatus = st; + _isOnline = st == 1; } } } else if (widget.chatType == 'CHAT') { @@ -162,25 +140,9 @@ class _ChatInfoScreenState extends State { if (id != null) memberIds.add(id); } - final Map presenceMap = {}; + Map> presenceMap = {}; if (memberIds.isNotEmpty) { - final pp = await main.api.sendRequest( - Opcode.contactPresence, - {'contactIds': memberIds}, - ); - if (pp.isOk) { - final presence = (pp.payload as Map?)?['presence'] as Map?; - if (presence != null) { - for (final e in presence.entries) { - final id = e.key is int - ? e.key as int - : int.tryParse(e.key.toString()); - if (id != null && e.value is Map) { - presenceMap[id] = e.value as Map; - } - } - } - } + presenceMap = await PresenceFetch.getMany(memberIds); } _onlineCount = 0; diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 655e5f6..ddd75c1 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -19,13 +19,16 @@ import '../auth/login_screen.dart'; import '../../widgets/account_switcher_overlay.dart'; import '../../../backend/api.dart'; import '../../../core/utils/haptics.dart'; +import '../../../core/config/app_stories.dart'; import '../../../backend/models/chat_folder.dart'; import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/cloud_storage.dart'; import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; -import '../../../main.dart' show accountModule, api, messagesModule; +import '../../../core/storage/token_storage.dart'; +import '../../../main.dart' + show accountModule, api, messagesModule, appRouteObserver; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -72,7 +75,7 @@ class ChatListScreen extends StatefulWidget { enum _DeleteKind { personalLike, ownerGroup, blocked } class _ChatListScreenState extends State - with TickerProviderStateMixin { + with TickerProviderStateMixin, RouteAware { String? _selectedFolderId; List _folders = []; @@ -81,7 +84,7 @@ class _ChatListScreenState extends State double _navPageAnimStart = 0; double _navPageAnimEnd = 0; - double _navDragDx = 0; + final ValueNotifier _navDragDx = ValueNotifier(0); double _navDragBaseLeft = 0; double _revealAnimBegin = 0.0; double _closeAnimBegin = 0.0; @@ -100,9 +103,11 @@ class _ChatListScreenState extends State bool _navDragging = false; bool _isFabOpen = false; - bool _showCacheWarning = false; bool _storiesAnimClosing = false; Timer? _contactRebuildTimer; + bool _deferReloads = false; + bool _reloadQueued = false; + Timer? _settleTimer; bool get _isSelectionMode => _selectedChats.isNotEmpty; bool? _foldersListKnown; @@ -140,11 +145,9 @@ class _ChatListScreenState extends State _selectedFolderId, _isInitialLoading, _foldersListKnown, - _showCacheWarning, _isSelectionMode, _shouldCollapseSearch, _selectedChats.length, - _pullRatio, _storiesDockedOpen, _storiesAnimClosing, _storiesOverscrollRevealArmed, @@ -180,8 +183,12 @@ class _ChatListScreenState extends State List _selectedChatObjects() { if (_selectedChats.isEmpty) return const []; - final ids = _selectedChats; - return _chats.where((c) => ids.contains(c.id.toString())).toList(); + final ids = {}; + for (final s in _selectedChats) { + final v = int.tryParse(s); + if (v != null) ids.add(v); + } + return _chats.where((c) => ids.contains(c.id)).toList(); } _DeleteKind _categorizeChat(CachedChat c, int myId) { @@ -404,6 +411,7 @@ class _ChatListScreenState extends State } bool _allowStoriesPullOverscrollTop() { + if (!AppStories.current.value) return false; if (_storiesDockedOpen || _storiesRevealController.isAnimating || _pullRatio > 0) { @@ -447,30 +455,73 @@ class _ChatListScreenState extends State if (mounted) { setState(() { _sessionState = state; - if (state == SessionState.disconnected && _chats.isNotEmpty) { - _showCacheWarning = true; - } - if (state == SessionState.online) { - _showCacheWarning = false; - } }); if (state == SessionState.online) { - _reloadChatsAndFolders(); + _requestReload(); } } }); _loginSub = accountModule.loginStatusStream.listen((status) { if (status == LoginStatus.success) { - _reloadChatsAndFolders(); + _requestReload(); } }); ChatsModule.chatsChanged.addListener(_onChatsChanged); + AppStories.current.addListener(_onStoriesEnabledChanged); + _reloadChatsAndFolders(); + } + + void _onStoriesEnabledChanged() { + if (!mounted) return; + if (!AppStories.current.value) { + _storiesRevealController.stop(); + _pullRatio = 0; + _storiesDockedOpen = false; + _storiesAnimClosing = false; + _storiesOverscrollRevealArmed = false; + } + setState(() {}); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute) { + appRouteObserver.subscribe(this, route); + } + } + + @override + void didPushNext() { + _deferReloads = true; + } + + @override + void didPopNext() { + _settleTimer?.cancel(); + _settleTimer = Timer(const Duration(milliseconds: 420), () { + if (!mounted) return; + _deferReloads = false; + if (_reloadQueued) { + _reloadQueued = false; + _reloadChatsAndFolders(); + } + }); + } + + void _requestReload() { + if (!mounted) return; + if (_deferReloads) { + _reloadQueued = true; + return; + } _reloadChatsAndFolders(); } void _onChatsChanged() { - if (mounted) _reloadChatsAndFolders(); + _requestReload(); } Future _reloadChatsAndFolders() async { @@ -619,7 +670,19 @@ class _ChatListScreenState extends State }); } + int? _pageChatsBaseKey; + final Map> _pageChatsCache = {}; + List _chatsForPageIndex(int pageIndex) { + final baseKey = + Object.hash(identityHashCode(_chats), identityHashCode(_folders)); + if (_pageChatsBaseKey != baseKey) { + _pageChatsBaseKey = baseKey; + _pageChatsCache.clear(); + } + final cached = _pageChatsCache[pageIndex]; + if (cached != null) return cached; + List base; if (_folders.isEmpty) { base = _chats; @@ -634,7 +697,9 @@ class _ChatListScreenState extends State final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList() ..sort((a, b) => a.favIndex!.compareTo(b.favIndex!)); final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList(); - return [...pinned, ...regular]; + final result = [...pinned, ...regular]; + _pageChatsCache[pageIndex] = result; + return result; } void _syncFolderChatScrollControllers() { @@ -928,7 +993,10 @@ class _ChatListScreenState extends State @override void dispose() { + appRouteObserver.unsubscribe(this); + _settleTimer?.cancel(); ChatsModule.chatsChanged.removeListener(_onChatsChanged); + AppStories.current.removeListener(_onStoriesEnabledChanged); _loginSub?.cancel(); _stateSub?.cancel(); _fabController.dispose(); @@ -947,6 +1015,7 @@ class _ChatListScreenState extends State } _contactRebuildTimer?.cancel(); _storiesUi.dispose(); + _navDragDx.dispose(); super.dispose(); } @@ -955,7 +1024,7 @@ class _ChatListScreenState extends State required double Function(int index) bubbleLeftForIndex, }) { if (_navDragging) { - final left = (_navDragBaseLeft + _navDragDx).clamp( + final left = (_navDragBaseLeft + _navDragDx.value).clamp( bubbleLeftForIndex(0), bubbleLeftForIndex(3), ); @@ -1028,7 +1097,8 @@ class _ChatListScreenState extends State children: [ Row( children: [ - if (_pullRatio < 0.8) + if (AppStories.current.value && + _pullRatio < 0.8) Opacity( opacity: 1.0 - _pullRatio, child: Container( @@ -1107,68 +1177,33 @@ class _ChatListScreenState extends State ], ), ), - SizedBox( - height: 96 * _pullRatio, - child: Opacity( - opacity: _pullRatio, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - children: [ - _buildStoryItem( - 'Даша', - 'https://i.pravatar.cc/150?u=dasha', - true, + if (AppStories.current.value) + SizedBox( + height: 96 * _pullRatio, + child: Opacity( + opacity: _pullRatio, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, ), - _buildStoryItem( - 'Мастика', - 'https://i.pravatar.cc/150?u=mastika', - false, - ), - ], - ), - ), - ), - if (_showCacheWarning) - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - color: cs.errorContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: cs.error.withValues(alpha: 0.2), - ), - ), - child: Row( children: [ - Icon( - Symbols.cloud_off, - size: 18, - color: cs.error, + _buildStoryItem( + 'Даша', + 'https://i.pravatar.cc/150?u=dasha', + true, ), - const SizedBox(width: 12), - const Expanded( - child: Text( - 'Ошибка соединения, сейчас вы смотрите КЕШ', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), + _buildStoryItem( + 'Мастика', + 'https://i.pravatar.cc/150?u=mastika', + false, ), ], ), ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), child: Container( height: 44, decoration: BoxDecoration( @@ -1321,7 +1356,7 @@ class _ChatListScreenState extends State parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ - const SliverToBoxAdapter(child: SizedBox(height: 14)), + const SliverToBoxAdapter(child: SizedBox(height: 8)), if (chats.isEmpty && !_isInitialLoading) SliverFillRemaining( child: Center( @@ -1343,6 +1378,7 @@ class _ChatListScreenState extends State if (hasSeparator && index == pinnedCount) { return Padding( + key: const ValueKey('pinned_divider'), padding: const EdgeInsets.symmetric(horizontal: 20), child: Divider( height: 1, @@ -1357,20 +1393,28 @@ class _ChatListScreenState extends State final isPinned = (chat.favIndex ?? 0) > 0; if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { - final secondId = chat.participants.entries - .where((entry) => entry.key != _profile?.id) - .first - .key; + int secondId = _profile?.id ?? 0; + for (final entry in chat.participants.entries) { + if (entry.key != _profile?.id) { + secondId = entry.key; + break; + } + } final name = ContactCache.get(secondId); final avatar = ContactCache.getAvatar(secondId); // ContactCache.isOfficial covers contacts loaded via opcode 32; // chat.isOfficial covers contacts from the login payload. final isVerified = ContactCache.isOfficial(secondId) || chat.isOfficial; + final isPlaceholder = + chat.lastMsgText == ChatsModule.lastMsgPlaceholder; + final previewText = isPlaceholder + ? 'зайдите в чат для подгрузки' + : (chat.lastMsgTextOneLine ?? ''); return _buildChatItem( chat.id.toString(), name ?? "Пользователь", - chat.lastMsgTextOneLine ?? '', + previewText, _formatTime(chat.lastMsgTime), avatar ?? "", isOnline: chat.isOnline, @@ -1379,20 +1423,25 @@ class _ChatListScreenState extends State isVerified: isVerified, isPinned: isPinned, chatType: "DIALOG", + messageItalic: isPlaceholder, ); } else { - final name = chat.lastMsgSenderId != null + final isPlaceholder = + chat.lastMsgText == ChatsModule.lastMsgPlaceholder; + final sender = chat.lastMsgSenderId != null ? ContactCache.get(chat.lastMsgSenderId!) : null; String fullMsg = ""; - - if (name?.isNotEmpty == true && chat.id != 0) { - fullMsg += "$name: "; - } - - if (chat.lastMsgText?.isNotEmpty == true) { - fullMsg += chat.lastMsgText ?? ""; + if (isPlaceholder) { + fullMsg = 'зайдите в чат для подгрузки'; + } else { + if (sender?.isNotEmpty == true && chat.id != 0) { + fullMsg += "$sender: "; + } + if (chat.lastMsgText?.isNotEmpty == true) { + fullMsg += chat.lastMsgText ?? ""; + } } return _buildChatItem( @@ -1409,6 +1458,7 @@ class _ChatListScreenState extends State isVerified: chat.isOfficial, isPinned: isPinned, chatType: chat.type, + messageItalic: isPlaceholder, ); } }, childCount: totalItems), @@ -1499,12 +1549,6 @@ class _ChatListScreenState extends State final minBubbleLeft = bubbleLeftForIndex(0); final maxBubbleLeft = bubbleLeftForIndex(3); - final bubbleLeft = _navDragging - ? (_navDragBaseLeft + _navDragDx).clamp(minBubbleLeft, maxBubbleLeft) - : leftOffset; - - final navRowT = ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); - double navInterpolatedWidth(int tabIndex, double rowT) { final rt = rowT.clamp(0.0, 3.0); final i0 = rt.floor().clamp(0, 3); @@ -1557,39 +1601,46 @@ class _ChatListScreenState extends State if (_isSelectionMode) return; _navPageAnimController.stop(); _navPageAnimController.value = 1.0; + _navDragDx.value = 0; setState(() { _navDragging = true; - _navDragDx = 0; _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); }); }, onHorizontalDragUpdate: (details) { if (!_navDragging) return; - setState(() { - _navDragDx += details.delta.dx; - }); + _navDragDx.value += details.delta.dx; }, onHorizontalDragEnd: (_) { if (!_navDragging) return; - final left = (_navDragBaseLeft + _navDragDx).clamp( + final left = (_navDragBaseLeft + _navDragDx.value).clamp( minBubbleLeft, maxBubbleLeft, ); final next = indexForBubbleLeft(left); + _navDragDx.value = 0; setState(() { _currentNavIndex = next; _navDragging = false; - _navDragDx = 0; }); }, onHorizontalDragCancel: () { if (!_navDragging) return; + _navDragDx.value = 0; setState(() { _navDragging = false; - _navDragDx = 0; }); }, - child: Stack( + child: ValueListenableBuilder( + valueListenable: _navDragDx, + builder: (context, navDragDx, _) { + final bubbleLeft = _navDragging + ? (_navDragBaseLeft + navDragDx) + .clamp(minBubbleLeft, maxBubbleLeft) + : leftOffset; + final navRowT = + ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); + return Stack( clipBehavior: Clip.hardEdge, children: [ AnimatedPositioned( @@ -1661,6 +1712,8 @@ class _ChatListScreenState extends State ), ), ], + ); + }, ), ), ), @@ -1706,7 +1759,8 @@ class _ChatListScreenState extends State width: pageW * 4, height: pageH, child: AnimatedBuilder( - animation: _navPageAnimController, + animation: Listenable.merge( + [_navPageAnimController, _navDragDx]), child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1812,9 +1866,7 @@ class _ChatListScreenState extends State onPressed: _toggleFab, backgroundColor: cs.primaryContainer, elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), + shape: const CircleBorder(), child: Transform.rotate( angle: val * (pi / 4), child: Icon( @@ -2020,7 +2072,7 @@ class _ChatListScreenState extends State padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(50), ), child: Text( title, @@ -2049,11 +2101,13 @@ class _ChatListScreenState extends State bool isVerified = false, bool isPinned = false, String chatType = "CHAT", + bool messageItalic = false, }) { final cs = Theme.of(context).colorScheme; final isSelected = _selectedChats.contains(id); return InkWell( + key: ValueKey('chat_$id'), onTap: () { if (_isSelectionMode) { _toggleSelection(id); @@ -2230,6 +2284,9 @@ class _ChatListScreenState extends State fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400, + fontStyle: messageItalic + ? FontStyle.italic + : FontStyle.normal, height: 1.2, ), maxLines: 1, @@ -2313,6 +2370,7 @@ class _ChatListScreenState extends State icon, color: isSelected ? cs.onPrimary : cs.onSurface, size: 20, + fill: 1, ), AnimatedContainer( duration: animDur, @@ -2355,9 +2413,16 @@ class _ChatListScreenState extends State controller.dispose(); if (!mounted) return; if (accountId == null) { - await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const LoginScreen()), + final previousId = await TokenStorage.getActiveAccountId(); + try { + await accountModule.beginAddAccount(); + } catch (_) {} + if (!mounted) return; + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => LoginScreen(returnToAccountId: previousId), + ), + (route) => false, ); return; } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 6dcd73d..35981c2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io' show File; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; @@ -13,17 +14,19 @@ import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; -import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/cache/info_cache.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; +import '../../../core/config/app_pranks.dart'; import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; +import '../../widgets/theme_reveal.dart'; import '../../widgets/message_actions_overlay.dart'; import '../../widgets/attachment_panel.dart'; import '../../widgets/swipe_to_pop.dart'; @@ -89,11 +92,42 @@ class _ChatScreenState extends State final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus()); StreamSubscription? _uploadSub; StreamSubscription? _pushSub; + StreamSubscription? _messageEventSub; + final Map?>> _reactionNotifiers = {}; + + ValueNotifier?> _reactionNotifierFor(CachedMessage m) { + final existing = _reactionNotifiers[m.id]; + if (existing != null) return existing; + final info = m.payload?['reactionInfo']; + final notifier = ValueNotifier?>( + info is Map ? Map.from(info) : null, + ); + _reactionNotifiers[m.id] = notifier; + return notifier; + } + + void _pruneReactionNotifiers() { + final liveIds = _messages.map((m) => m.id).toSet(); + final dead = _reactionNotifiers.keys.where((id) => !liveIds.contains(id)).toList(); + for (final id in dead) { + _reactionNotifiers.remove(id)?.dispose(); + } + } final Set _typingUserIds = {}; final Map _typingTimers = {}; int _otherStatus = 0; int? _otherSeenTime; + int? _participantsCount; + + bool _prankActive = false; + String? _prankBubbleId; + final GlobalKey _prankBubbleKey = GlobalKey(); + final GlobalKey _prankCaptureKey = GlobalKey(); + OverlayEntry? _prankRevealEntry; + AnimationController? _prankRevealController; + ui.Image? _prankRevealImage; final ValueNotifier _headerStatusNotifier = ValueNotifier(''); + final ValueNotifier _otherReadTime = ValueNotifier(0); int _tempIdCounter = 0; late final AnimationController _attachAnim; @@ -102,6 +136,10 @@ class _ChatScreenState extends State Timer? _shimmerStartTimer; bool _historyKickedOff = false; List _messages = []; + int _messagesRevision = 0; + List? _combinedItemsCache; + int? _combinedItemsKey; + bool _floatingDateScheduled = false; int _myId = 0; CachedChat? chat; @@ -110,7 +148,6 @@ class _ChatScreenState extends State late final AnimationController _floatingDateAnimController; late final CurvedAnimation _floatingDateCurved; final Map _separatorKeys = {}; - double _lastScrollOffset = 0; String? _lastSentId; @override @@ -130,10 +167,12 @@ class _ChatScreenState extends State _showAttachmentPanel.addListener(_onAttachPanelToggle); _pushSub = api.pushStream .where((p) => - p.opcode == Opcode.notifMessage || p.opcode == Opcode.notifMark || p.opcode == Opcode.notifTyping) .listen(_onIncomingPush); + _messageEventSub = ChatsModule.messageEvents + .where((e) => e.chatId == widget.chatId) + .listen(_onMessageEvent); _floatingDateAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), @@ -145,9 +184,56 @@ class _ChatScreenState extends State reverseCurve: Curves.easeIn, ); + unawaited(_fastPreloadCache()); + unawaited(_loadParticipantsCount()); WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered); } + Future _loadParticipantsCount() async { + if (widget.chatType != 'CHAT' && widget.chatType != 'CHANNEL') return; + final info = await ChatsModule.getChatInfo(api, widget.chatId); + if (!mounted) return; + final count = info?['participantsCount'] as int?; + if (count != null && count != _participantsCount) { + _participantsCount = count; + _recomputeHeaderStatus(); + } + } + + Future _fastPreloadCache() async { + final p = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + _myId = p?.id ?? 0; + + ChatsModule.getChat(_myId, widget.chatId).then((value) { + if (mounted && value.isNotEmpty) { + setState(() { + chat = value.first; + }); + _recomputeHeaderStatus(); + _syncOtherReadTime(); + } + }).catchError((_) {}); + + final firstRows = await AppDatabase.loadMessages( + _myId, + widget.chatId, + limit: 20, + ); + if (!mounted) return; + if (firstRows.isNotEmpty) { + final first = firstRows.reversed + .map((r) => CachedMessage.fromDbRow(r)) + .toList(); + setState(() { + _messages = first; + _messagesRevision++; + _isLoading = false; + _onLoadingFinished(); + }); + } + } + void _onFirstFrameRendered(Duration _) { if (!mounted) return; if (widget.embedded) { @@ -192,37 +278,15 @@ class _ChatScreenState extends State } Future _loadHistory() async { - final activeProfile = await AppDatabase.loadActiveProfile(); - _myId = activeProfile?.id ?? 0; - ChatsModule.getChat(_myId, widget.chatId).then((value) { - if (mounted && value.isNotEmpty) { - setState(() { chat = value.first; }); - _recomputeHeaderStatus(); - } - }).catchError((_) {}); + if (_myId == 0) { + final activeProfile = await AppDatabase.loadActiveProfile(); + if (!mounted) return; + _myId = activeProfile?.id ?? 0; + } if (widget.chatType == 'DIALOG') { unawaited(_loadOtherPresence()); } - - final firstRows = await AppDatabase.loadMessages( - _myId, - widget.chatId, - limit: 20, - ); - if (mounted && firstRows.isNotEmpty) { - final first = firstRows.reversed - .map((r) => CachedMessage.fromDbRow(r)) - .toList(); - setState(() { - _messages = first; - if (api.state == SessionState.online) { - _isLoading = false; - _onLoadingFinished(); - } - }); - } - - unawaited(_loadRemainingHistory()); + await _loadRemainingHistory(); } Future _loadRemainingHistory() async { @@ -235,8 +299,20 @@ class _ChatScreenState extends State _applyMergedMessages(fullRows); } + if (!ChatsModule.isChatDirty(widget.chatId) && fullRows.isNotEmpty) { + if (mounted) { + setState(() { + _isLoading = false; + _onLoadingFinished(); + }); + } + _loadForwardedSenderNames(); + return; + } + try { await messagesModule.fetchHistory(_myId, widget.chatId); + ChatsModule.markChatClean(widget.chatId); final updatedRows = await AppDatabase.loadMessages( _myId, widget.chatId, @@ -245,6 +321,7 @@ class _ChatScreenState extends State if (mounted) { _applyMergedMessages(updatedRows, markLoaded: true); } + unawaited(ChatsModule.reconcileLastMessageIfPlaceholder(_myId, widget.chatId)); _loadForwardedSenderNames(); } catch (e) { debugPrint('Error fetching history: $e'); @@ -274,12 +351,42 @@ class _ChatScreenState extends State final changed = !_listsEquivalent(_messages, merged); if (!changed && !markLoaded) return; setState(() { - if (changed) _messages = merged; + if (changed) { + _messages = merged; + _messagesRevision++; + } if (markLoaded) { _isLoading = false; _onLoadingFinished(); } }); + if (changed) { + _syncReactionNotifiersFromMessages(); + _pruneReactionNotifiers(); + } + } + + void _syncReactionNotifiersFromMessages() { + for (final m in _messages) { + final info = m.payload?['reactionInfo']; + final value = info is Map ? Map.from(info) : null; + final existing = _reactionNotifiers[m.id]; + if (existing == null) { + _reactionNotifiers[m.id] = ValueNotifier(value); + } else if (!_reactionsEqual(existing.value, value)) { + existing.value = value; + } + } + } + + bool _reactionsEqual(Map? a, Map? b) { + if (identical(a, b)) return true; + if (a == null || b == null) return false; + if (a.length != b.length) return false; + for (final k in a.keys) { + if (a[k].toString() != b[k].toString()) return false; + } + return true; } bool _sameMessage(CachedMessage a, CachedMessage b) { @@ -311,11 +418,18 @@ class _ChatScreenState extends State _showAttachmentPanel.dispose(); _uploadSub?.cancel(); _pushSub?.cancel(); + _messageEventSub?.cancel(); + for (final n in _reactionNotifiers.values) { + n.dispose(); + } + _reactionNotifiers.clear(); for (final t in _typingTimers.values) { t.cancel(); } _typingTimers.clear(); _headerStatusNotifier.dispose(); + _otherReadTime.dispose(); + _finishPrankReveal(); _uploadStatus.dispose(); _attachAnim.dispose(); _messageController.dispose(); @@ -340,17 +454,124 @@ class _ChatScreenState extends State } } - String? _effectiveStatus(CachedMessage msg) { - if (msg.senderId != _myId) return null; - if (msg.status == 'sending' || msg.status == 'error') return msg.status; + int _computeOtherReadTime() { final c = chat; - if (c == null) return 'sent'; + if (c == null) return 0; int otherReadTime = 0; for (final entry in c.participants.entries) { if (entry.key != _myId && entry.value > otherReadTime) { otherReadTime = entry.value; } } + return otherReadTime; + } + + void _syncOtherReadTime() { + final t = _computeOtherReadTime(); + if (_otherReadTime.value != t) _otherReadTime.value = t; + } + + void _checkPrankTrigger(CachedMessage msg) { + if (!AppPranks.current.value || _prankActive || _prankBubbleId != null) { + return; + } + if ((msg.text ?? '').trim().toUpperCase() != 'THE WORLD') return; + setState(() => _prankBubbleId = msg.id); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _runPrankReveal(); + }); + } + + ThemeData _prankPinkTheme(ThemeData base) { + final cs = base.colorScheme; + return base.copyWith( + scaffoldBackgroundColor: const Color(0xFFFFF0F5), + colorScheme: cs.copyWith( + surface: const Color(0xFFFFF0F5), + surfaceContainerHigh: const Color(0xFFFFE3EC), + surfaceContainerHighest: const Color(0xFFFFD9E6), + primary: const Color(0xFFE8579A), + primaryContainer: const Color(0xFFFFD6E5), + onPrimaryContainer: const Color(0xFF7A1F4B), + ), + ); + } + + void _runPrankReveal() { + if (_prankActive) return; + final overlay = Navigator.of(context).overlay; + final captureCtx = _prankCaptureKey.currentContext; + final renderObject = captureCtx?.findRenderObject(); + if (overlay == null || renderObject is! RenderRepaintBoundary) { + setState(() => _prankActive = true); + return; + } + + Offset center; + final bubbleBox = + _prankBubbleKey.currentContext?.findRenderObject() as RenderBox?; + if (bubbleBox != null && bubbleBox.attached) { + center = bubbleBox.localToGlobal(bubbleBox.size.center(Offset.zero)); + } else { + final size = MediaQuery.sizeOf(context); + center = Offset(size.width / 2, size.height / 2); + } + + final ui.Image snapshot; + try { + final dpr = math.min(MediaQuery.of(context).devicePixelRatio, 2.0); + snapshot = renderObject.toImageSync(pixelRatio: dpr); + } catch (_) { + setState(() => _prankActive = true); + return; + } + + _finishPrankReveal(); + + final controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 650), + ); + final entry = ThemeRevealOverlay.build( + snapshot: snapshot, + center: center, + animation: controller, + ); + + _prankRevealController = controller; + _prankRevealEntry = entry; + _prankRevealImage = snapshot; + + overlay.insert(entry); + setState(() => _prankActive = true); + Haptics.success(); + + WidgetsBinding.instance.endOfFrame.then((_) { + if (_prankRevealController != controller) return; + controller.forward().then((_) { + if (_prankRevealController != controller) return; + _finishPrankReveal(); + }, onError: (_) {}); + }); + } + + void _finishPrankReveal() { + _prankRevealEntry?.remove(); + _prankRevealEntry = null; + _prankRevealController?.dispose(); + _prankRevealController = null; + final img = _prankRevealImage; + _prankRevealImage = null; + if (img != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose()); + } + } + + String? _effectiveStatus(CachedMessage msg) { + if (msg.senderId != _myId) return null; + if (msg.status == 'sending' || msg.status == 'error') return msg.status; + if (chat == null) return 'sent'; + final otherReadTime = _otherReadTime.value; if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read'; return 'sent'; } @@ -358,8 +579,6 @@ class _ChatScreenState extends State void _onIncomingPush(Packet packet) { if (!mounted) return; switch (packet.opcode) { - case Opcode.notifMessage: - _onIncomingMessage(packet); case Opcode.notifMark: _onMessageRead(packet); case Opcode.notifTyping: @@ -367,23 +586,51 @@ class _ChatScreenState extends State } } + void _onMessageEvent(MessageEvent event) { + if (!mounted) return; + switch (event) { + case MessageAddedEvent(:final message): + if (message.senderId == _myId) return; + if (_messages.any((m) => m.id == message.id)) return; + setState(() { + _lastSentId = message.id; + _messages.add(message); + _messagesRevision++; + }); + _clearTyping(message.senderId); + Haptics.tap(); + _scrollToBottom(); + _checkPrankTrigger(message); + case MessageEditedEvent(:final message): + final idx = _messages.indexWhere((m) => m.id == message.id); + if (idx == -1) return; + setState(() { + _messages[idx] = message; + _messagesRevision++; + }); + case MessageRemovedEvent(:final messageId): + final idx = _messages.indexWhere((m) => m.id == messageId); + if (idx == -1) return; + setState(() { + _messages.removeAt(idx); + _messagesRevision++; + }); + _reactionNotifiers.remove(messageId)?.dispose(); + case MessageReactionsChangedEvent(:final messageId, :final reactionInfo): + _reactionNotifiers[messageId]?.value = reactionInfo; + } + } + Future _loadOtherPresence() async { if (_myId == 0) return; final otherId = widget.chatId ^ _myId; if (otherId <= 0) return; try { - final p = await api.sendRequest( - Opcode.contactPresence, - {'contactIds': [otherId]}, - ); - if (!mounted) return; - final presence = (p.payload as Map?)?['presence'] as Map?; - final entry = presence?[otherId.toString()] ?? presence?[otherId]; - if (entry is Map) { - _otherStatus = (entry['status'] as int?) ?? 0; - _otherSeenTime = entry['seen'] as int?; - _recomputeHeaderStatus(); - } + final entry = await PresenceFetch.get(otherId); + if (!mounted || entry == null) return; + _otherStatus = (entry['status'] as int?) ?? 0; + _otherSeenTime = entry['seen'] as int?; + _recomputeHeaderStatus(); } catch (_) {} } @@ -408,10 +655,12 @@ class _ChatScreenState extends State String _headerStatus() { if (_typingUserIds.isNotEmpty) return 'Печатает...'; if (widget.chatType == 'CHAT') { - return '${chat?.participants.length ?? 0} участников'; + final count = _participantsCount ?? chat?.participants.length ?? 0; + return '$count участников'; } if (widget.chatType == 'CHANNEL') { - return '${chat?.participants.length ?? 0} подписчиков'; + final count = _participantsCount ?? chat?.participants.length ?? 0; + return '$count подписчиков'; } if (_otherStatus == 1) return 'В сети'; if (_otherStatus == 3) return 'Был(-а) недавно'; @@ -458,56 +707,8 @@ class _ChatScreenState extends State final c = chat; if (c == null) return; if (c.participants[userId] == mark) return; - setState(() { - c.participants[userId] = mark; - }); - } - - void _onIncomingMessage(Packet packet) { - if (!mounted) return; - final payload = packet.payload; - if (payload is! Map) return; - final chatId = payload['chatId']; - if (chatId != widget.chatId) return; - final msg = payload['message']; - if (msg is! Map) return; - - final senderId = msg['sender']; - if (senderId is! int) return; - if (senderId == _myId) return; - - final msgId = msg['id']?.toString(); - if (msgId == null || msgId.isEmpty) return; - if (_messages.any((m) => m.id == msgId)) return; - - List? attachments; - final attaches = msg['attaches']; - if (attaches is List && attaches.isNotEmpty) { - attachments = attaches - .whereType() - .map((a) => MessageAttachment.fromMap(Map.from(a))) - .toList(); - } - - final cached = CachedMessage( - id: msgId, - accountId: _myId, - chatId: widget.chatId, - senderId: senderId, - text: msg['text'] as String?, - time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, - status: 'sent', - payload: Map.from(msg), - attachments: attachments, - ); - - setState(() { - _lastSentId = msgId; - _messages.add(cached); - }); - _clearTyping(senderId); - Haptics.tap(); - _scrollToBottom(); + c.participants[userId] = mark; + _syncOtherReadTime(); } Future _sendMessage() async { @@ -533,30 +734,36 @@ class _ChatScreenState extends State setState(() { _lastSentId = tempId; _messages.add(tempMessage); + _messagesRevision++; _messageController.clear(); }); + unawaited(_persistOutgoing(tempMessage)); // Instant tactile "whoosh" the moment the message leaves the composer, // not after the network round-trip — feedback must feel immediate. Haptics.send(); _scrollToBottom(); + _checkPrankTrigger(tempMessage); final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { + final sent = CachedMessage( + id: actualId.isNotEmpty ? actualId : tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'sent', + ); setState(() { - _messages[index] = CachedMessage( - id: actualId.isNotEmpty ? actualId : tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: text, - time: now, - status: 'sent', - ); + _messages[index] = sent; + _messagesRevision++; }); + unawaited(_persistOutgoing(sent, removeId: tempId)); } if (chat == null) { @@ -564,6 +771,7 @@ class _ChatScreenState extends State ChatsModule.refreshChats(api, [widget.chatId]).then((list) { if (!mounted || list.isEmpty) return; setState(() => chat = list.first); + _syncOtherReadTime(); }), ); } @@ -571,21 +779,33 @@ class _ChatScreenState extends State Haptics.error(); final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { + final failed = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'error', + ); setState(() { - _messages[index] = CachedMessage( - id: tempId, - accountId: _myId, - chatId: widget.chatId, - senderId: _myId, - text: text, - time: now, - status: 'error', - ); + _messages[index] = failed; + _messagesRevision++; }); + unawaited(_persistOutgoing(failed)); } } } + Future _persistOutgoing(CachedMessage msg, {String? removeId}) async { + try { + if (removeId != null && removeId != msg.id) { + await AppDatabase.deleteMessage(_myId, widget.chatId, removeId); + } + await AppDatabase.saveMessages([msg.toDbRow()]); + } catch (_) {} + } + Future _loadForwardedSenderNames() async { final forwardIds = {}; for (final msg in _messages) { @@ -601,48 +821,61 @@ class _ChatScreenState extends State } if (forwardIds.isEmpty) return; + final resolved = {}; for (final id in forwardIds) { final name = await messagesModule.searchContactById(id); - final avatar = ContactCache.getAvatar(id); - if (name != null && mounted) { - setState(() { - for (var i = 0; i < _messages.length; i++) { - final msg = _messages[i]; - if (msg.attachments != null) { - final newAttaches = msg.attachments!.map((a) { - if (a is ForwardedMessageAttachment && - a.originalSenderId == id && - a.originalSenderName == null) { - return ForwardedMessageAttachment( - originalSenderId: id, - originalSenderName: name, - originalSenderAvatar: avatar, - originalMessageId: a.originalMessageId, - originalTime: a.originalTime, - originalText: a.originalText, - originalChatId: a.originalChatId, - originalAttachments: a.originalAttachments, - originalContact: a.originalContact, - ); - } - return a; - }).toList(); - _messages[i] = CachedMessage( - id: msg.id, - accountId: msg.accountId, - chatId: msg.chatId, - senderId: msg.senderId, - text: msg.text, - time: msg.time, - status: msg.status, - payload: msg.payload, - attachments: newAttaches, - ); - } - } - }); + if (name != null) { + resolved[id] = (name: name, avatar: ContactCache.getAvatar(id)); } } + if (resolved.isEmpty || !mounted) return; + + var anyChanged = false; + for (var i = 0; i < _messages.length; i++) { + final msg = _messages[i]; + final attaches = msg.attachments; + if (attaches == null) continue; + + var msgChanged = false; + final newAttaches = attaches.map((a) { + if (a is ForwardedMessageAttachment && + a.originalSenderName == null && + resolved.containsKey(a.originalSenderId)) { + final r = resolved[a.originalSenderId]!; + msgChanged = true; + return ForwardedMessageAttachment( + originalSenderId: a.originalSenderId, + originalSenderName: r.name, + originalSenderAvatar: r.avatar, + originalMessageId: a.originalMessageId, + originalTime: a.originalTime, + originalText: a.originalText, + originalChatId: a.originalChatId, + originalAttachments: a.originalAttachments, + originalContact: a.originalContact, + ); + } + return a; + }).toList(); + + if (!msgChanged) continue; + anyChanged = true; + _messages[i] = CachedMessage( + id: msg.id, + accountId: msg.accountId, + chatId: msg.chatId, + senderId: msg.senderId, + text: msg.text, + time: msg.time, + status: msg.status, + payload: msg.payload, + attachments: newAttaches, + ); + } + + if (anyChanged) { + setState(() => _messagesRevision++); + } } void _scrollToBottom() { @@ -658,6 +891,10 @@ class _ChatScreenState extends State } List _buildCombinedItems() { + final key = Object.hash(_messagesRevision, _messages.length); + final cached = _combinedItemsCache; + if (cached != null && _combinedItemsKey == key) return cached; + final List items = []; final Set usedDates = {}; @@ -690,30 +927,29 @@ class _ChatScreenState extends State } _separatorKeys.removeWhere((k, _) => !usedDates.contains(k)); + _combinedItemsCache = items; + _combinedItemsKey = key; return items; } void _onScrollForDate() { if (!_scrollController.hasClients) return; - final currentOffset = _scrollController.position.pixels; - final scrollingUp = currentOffset > _lastScrollOffset; - _lastScrollOffset = currentOffset; _floatingDateTimer?.cancel(); - - if (!scrollingUp) { - _floatingDateAnimController.reverse(); - return; - } - - _floatingDateTimer = Timer(const Duration(seconds: 2), () { + _floatingDateTimer = Timer(const Duration(seconds: 1), () { if (mounted) _floatingDateAnimController.reverse(); }); - WidgetsBinding.instance.addPostFrameCallback((_) => _updateFloatingDate()); + + if (_floatingDateScheduled) return; + _floatingDateScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _floatingDateScheduled = false; + _updateFloatingDate(); + }); } void _updateFloatingDate() { - if (!mounted) return; + if (!mounted || _separatorKeys.isEmpty) return; DateTime? result; final listRenderBox = _listKey.currentContext?.findRenderObject(); @@ -792,11 +1028,17 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; + final theme = + _prankActive ? _prankPinkTheme(Theme.of(context)) : Theme.of(context); + final cs = theme.colorScheme; // TODO: Локализация // TODO: Cклонения - return ValueListenableBuilder( + return Theme( + data: theme, + child: RepaintBoundary( + key: _prankCaptureKey, + child: ValueListenableBuilder( valueListenable: AppSwipeBackDesktop.current, builder: (context, desktopSwipe, child) => SwipeToPop( enabled: widget.embedded && desktopSwipe, @@ -952,6 +1194,8 @@ class _ChatScreenState extends State ], ), ), + ), + ), ); } @@ -972,7 +1216,9 @@ class _ChatScreenState extends State return Stack( key: _listKey, children: [ - ValueListenableBuilder( + ValueListenableBuilder( + valueListenable: _otherReadTime, + builder: (context, _, _) => ValueListenableBuilder( valueListenable: AppCacheExtent.current, builder: (context, cacheExtent, _) => ListView.builder( controller: _scrollController, @@ -1006,6 +1252,7 @@ class _ChatScreenState extends State nextMessage: nextMessage, chatType: chat?.type ?? 'CHAT', overrideStatus: _effectiveStatus(message), + reactionsListenable: _reactionNotifierFor(message), ); final pressable = _LongPressBubble( @@ -1024,13 +1271,17 @@ class _ChatScreenState extends State ) : pressable; - return RepaintBoundary( + final builtItem = RepaintBoundary( key: ValueKey('msg_${message.id}'), child: child, ); + return message.id == _prankBubbleId + ? KeyedSubtree(key: _prankBubbleKey, child: builtItem) + : builtItem; }, ), ), + ), Positioned( top: 8, left: 0, @@ -1385,6 +1636,7 @@ class _ChatScreenState extends State setState(() { _lastSentId = tempId; _messages.add(msg); + _messagesRevision++; }); Haptics.send(); _scrollToBottom(); @@ -1412,6 +1664,7 @@ class _ChatScreenState extends State payload: old.payload, attachments: attachment != null ? [attachment] : old.attachments, ); + _messagesRevision++; }); } diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index 3f6b3ea..c35dca9 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -8,6 +8,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/contacts.dart'; import '../../../core/storage/token_storage.dart'; +import '../../../core/utils/image_utils.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/swipe_route.dart'; @@ -131,16 +132,20 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { if (_avatar != null) { final url = await ChatsModule.requestChatPhotoUploadUrl(api); if (url != null) { - final bytes = await _avatar!.readAsBytes(); - final token = await fileUploader.uploadImage( - Uri.parse(url), - bytes, - filename: _avatar!.uri.pathSegments.last, - ); - if (token != null) { - await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); - } else if (mounted) { - showCustomNotification(context, 'Не удалось загрузить аватарку'); + final bytes = await compressAvatar(await _avatar!.readAsBytes()); + if (bytes == null) { + if (mounted) showCustomNotification(context, 'Не удалось обработать аватарку'); + } else { + final token = await fileUploader.uploadImage( + Uri.parse(url), + bytes, + filename: 'avatar.jpg', + ); + if (token != null) { + await ChatsModule.setChatPhoto(api, chatId: chat.id, photoToken: token); + } else if (mounted) { + showCustomNotification(context, 'Не удалось загрузить аватарку'); + } } } } diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index e502a18..b433648 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -2,10 +2,9 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../core/protocol/opcode_map.dart'; +import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; -import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/swipe_route.dart'; import '../chats/chat_screen.dart'; @@ -41,25 +40,18 @@ class _ContactProfileScreenState extends State { Future _load() async { try { final results = await Future.wait([ - api.sendRequest(Opcode.contactInfo, {'contactIds': [widget.contactId]}), - api.sendRequest(Opcode.contactPresence, {'contactIds': [widget.contactId]}), + ContactInfoFetch.get(widget.contactId), + PresenceFetch.get(widget.contactId), ]); if (!mounted) return; - final infoPacket = results[0]; - if (infoPacket.isOk) { - final contacts = (infoPacket.payload as Map?)?['contacts'] as List?; - if (contacts != null && contacts.isNotEmpty) { - _contact = Map.from(contacts.first as Map); - } + final contact = results[0]; + if (contact != null) { + _contact = contact; } - final presencePacket = results[1]; - if (presencePacket.isOk) { - final presence = (presencePacket.payload as Map?)?['presence'] as Map?; - final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId]; - if (p is Map) { - _seenTime = p['seen'] as int?; - _presenceStatus = (p['status'] as int?) ?? 0; - } + final presence = results[1]; + if (presence != null) { + _seenTime = presence['seen'] as int?; + _presenceStatus = (presence['status'] as int?) ?? 0; } } catch (e) { if (mounted) showCustomNotification(context, 'Ошибка: $e'); diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index 1f353b0..a08b595 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -43,7 +43,7 @@ class _CloudStorageScreenState extends State int? _accountId; List _files = []; bool _isUploading = false; - double _uploadProgress = 0; + final ValueNotifier _uploadProgress = ValueNotifier(0); bool _animateNewCard = false; @override @@ -68,16 +68,19 @@ class _CloudStorageScreenState extends State } mgr.onProgress = (progress, _) { if (!mounted) return; - setState(() { _isUploading = true; _uploadProgress = progress; }); + if (!_isUploading) setState(() => _isUploading = true); + _uploadProgress.value = progress; }; mgr.onDone = (file) { if (!mounted) return; - setState(() { _isUploading = false; _uploadProgress = 0; }); + _uploadProgress.value = 0; + setState(() => _isUploading = false); _prependFile(file); }; mgr.onError = (msg) { if (!mounted) return; - setState(() { _isUploading = false; _uploadProgress = 0; }); + _uploadProgress.value = 0; + setState(() => _isUploading = false); showCustomNotification(context, 'Ошибка: $msg'); }; } @@ -91,6 +94,7 @@ class _CloudStorageScreenState extends State _mode.dispose(); _pageController.dispose(); _currentFilePage.dispose(); + _uploadProgress.dispose(); super.dispose(); } @@ -218,7 +222,8 @@ class _CloudStorageScreenState extends State final picked = result.files.first; if (picked.path == null) return; - setState(() { _isUploading = true; _uploadProgress = 0; }); + _uploadProgress.value = 0; + setState(() => _isUploading = true); await UploadManager.instance.start( chatId: chatId, @@ -427,17 +432,27 @@ class _CloudStorageScreenState extends State const SizedBox(height: 16), ], if (_isUploading) ...[ - LinearProgressIndicator( - value: _uploadProgress, - borderRadius: BorderRadius.circular(4), - minHeight: 5, - color: cs.primary, - backgroundColor: cs.surfaceContainerHighest, - ), - const SizedBox(height: 8), - Text( - 'Загрузка ${(_uploadProgress * 100).toStringAsFixed(0)}%', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ValueListenableBuilder( + valueListenable: _uploadProgress, + builder: (context, progress, _) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LinearProgressIndicator( + value: progress, + borderRadius: BorderRadius.circular(4), + minHeight: 5, + color: cs.primary, + backgroundColor: cs.surfaceContainerHighest, + ), + const SizedBox(height: 8), + Text( + 'Загрузка ${(progress * 100).toStringAsFixed(0)}%', + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), ), ] else if (_files.isEmpty) ...[ Text( diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index b607079..f07c0e4 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -4,11 +4,18 @@ import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; +import '../../../core/config/app_pranks.dart'; +import '../../../core/config/app_stories.dart'; +import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; +import '../../../core/storage/app_database.dart'; import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; +import '../../../core/utils/media_cache.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/login_success_screen.dart'; +import '../calls/call_screen.dart'; class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); @@ -23,6 +30,92 @@ class _DebugMenuScreenState extends State { bool _hasSearched = false; final List<_SearchHit> _hits = []; final Map _errors = {}; + int _cacheSize = 0; + bool _clearingCache = false; + + @override + void initState() { + super.initState(); + _loadCacheSize(); + } + + Future _loadCacheSize() async { + final size = await MediaCache.currentSize(); + if (mounted) setState(() => _cacheSize = size); + } + + Future _clearCache() async { + if (_clearingCache) return; + setState(() => _clearingCache = true); + final freed = await MediaCache.clear(); + if (!mounted) return; + setState(() { + _clearingCache = false; + _cacheSize = 0; + }); + showCustomNotification(context, 'Кэш очищен (${_formatBytes(freed)})'); + } + + void _pickCacheLimit() { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Лимит кэша медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + for (final preset in AppMediaCacheLimit.presets) + ListTile( + title: Text( + _limitLabel(preset), + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + trailing: AppMediaCacheLimit.current.value == preset + ? Icon(Symbols.check, color: cs.primary) + : null, + onTap: () { + AppMediaCacheLimit.save(preset); + Navigator.pop(sheetContext); + setState(() {}); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + String _limitLabel(int bytes) => + bytes <= 0 ? 'Без лимита' : _formatBytes(bytes); + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes Б'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} КБ'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} МБ'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} ГБ'; + } @override void dispose() { @@ -397,6 +490,404 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppPranks.current, + builder: (context, pranksOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.auto_awesome, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Приколь4ики', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Switch( + value: pranksOn, + onChanged: (v) { + AppPranks.save(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: AppStories.current, + builder: (context, storiesOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.amp_stories, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Истории', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Отображение ленты историй в списке чатов', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: storiesOn, + onChanged: (v) { + AppStories.save(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: _pickCacheLimit, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.data_usage, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Лимит кэша медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _limitLabel(AppMediaCacheLimit.current.value), + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: _clearingCache ? null : _clearCache, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.delete_sweep, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Очистить кэш медиа', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + _clearingCache + ? 'Очистка…' + : 'Занято: ${_formatBytes(_cacheSize)}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + if (_clearingCache) + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () async { + final profile = await AppDatabase.loadActiveProfile(); + if (!context.mounted) return; + final avatar = await precacheLoginAvatar( + context, + profile?.baseUrl, + ); + if (!context.mounted) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + LoginSuccessScreen(preview: true, avatar: avatar), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.celebration, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'test hello', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Показать приветственную анимацию входа', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Icon( + Symbols.chevron_right, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + ], + ), + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Экран звонка', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + 'Превью экранов звонков', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _DebugCallButton( + label: 'Входящий', + icon: Symbols.call_received, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen( + name: 'Кирил Г.', + initialState: CallScreenState.incoming, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DebugCallButton( + label: 'Исходящий', + icon: Symbols.call_made, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen( + name: 'Кирил Г.', + initialState: CallScreenState.outgoing, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DebugCallButton( + label: 'Активный', + icon: Symbols.phone_in_talk, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen( + name: 'Кирил Г.', + initialState: CallScreenState.active, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -813,4 +1304,47 @@ class _ErrorChip extends StatelessWidget { ), ); } +} + +class _DebugCallButton extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onTap; + + const _DebugCallButton({ + required this.label, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, fill: 1), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } } \ No newline at end of file diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart index 44c2099..200bc49 100644 --- a/lib/frontend/screens/profile/edit_profile_screen.dart +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -1,10 +1,14 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/utils/image_utils.dart'; import '../../../l10n/app_localizations.dart'; -import '../../../main.dart' show accountModule, KometApp; +import '../../../main.dart' show accountModule, fileUploader, KometApp; import '../../widgets/custom_notification.dart'; +const int _maxAvatarBytes = 8 * 1024 * 1024; + class EditProfileScreen extends StatefulWidget { const EditProfileScreen({super.key}); @@ -77,12 +81,56 @@ class _EditProfileScreenState extends State { Future _changeAvatar() async { if (_isSaving) return; + final result = await FilePicker.platform.pickFiles( + type: FileType.image, + withData: true, + ); + if (result == null || result.files.isEmpty) return; + final picked = result.files.first; + final bytes = picked.bytes; + if (bytes == null) { + if (mounted) showCustomNotification(context, 'Не удалось прочитать файл'); + return; + } + if (bytes.length > _maxAvatarBytes) { + if (mounted) showCustomNotification(context, 'Картинка слишком большая (макс 8 МБ)'); + return; + } + if (!mounted) return; + setState(() => _isSaving = true); try { - final uploadUrl = await accountModule.getAvatarUploadUrl(); + final processed = await compressAvatar(bytes); + if (processed == null) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось обработать изображение'); + setState(() => _isSaving = false); + return; + } + final url = await accountModule.getAvatarUploadUrl(); + final token = await fileUploader.uploadImage( + Uri.parse(url), + processed, + filename: 'avatar.jpg', + ); + if (token == null) { + if (!mounted) return; + showCustomNotification(context, 'Не удалось загрузить аватарку'); + setState(() => _isSaving = false); + return; + } + final newProfile = await accountModule.updateProfileAvatar(token); if (!mounted) return; - showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)'); + setState(() { + _avatarUrl = newProfile.baseUrl; + _photoId = newProfile.photoId; + _isSaving = false; + }); + KometApp.stateOf(context)?.notifyProfileUpdate(); + showCustomNotification(context, 'Аватарка обновлена'); } catch (e) { - if (mounted) showCustomNotification(context, 'Ошибка: $e'); + if (!mounted) return; + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isSaving = false); } } diff --git a/lib/frontend/screens/profile/font_settings_screen.dart b/lib/frontend/screens/profile/font_settings_screen.dart index ba3fd15..7436b6a 100644 --- a/lib/frontend/screens/profile/font_settings_screen.dart +++ b/lib/frontend/screens/profile/font_settings_screen.dart @@ -378,7 +378,9 @@ class _FontSizeControl extends StatelessWidget { value: AppFonts.clampScale(scale), min: AppFonts.minScale, max: AppFonts.maxScale, - divisions: 10, + divisions: + ((AppFonts.maxScale - AppFonts.minScale) / 0.05) + .round(), onChanged: onChanged, onChangeEnd: onChangeEnd, ), diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart new file mode 100644 index 0000000..ee5b9f5 --- /dev/null +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class NotificationsScreen extends StatefulWidget { + const NotificationsScreen({super.key}); + + @override + State createState() => _NotificationsScreenState(); +} + +class _NotificationsScreenState extends State { + bool _fkmEnabled = false; + bool _personalChatsEnabled = true; + bool _groupsEnabled = true; + bool _channelsEnabled = true; + String _selectedSound = 'По умолчанию'; + + static const List _sounds = [ + 'По умолчанию', + 'Колокольчик', + 'Звон', + 'Капля', + 'Беззвучно', + ]; + + Future _pickSound() async { + final cs = Theme.of(context).colorScheme; + final picked = await showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + child: Row( + children: [ + Text( + 'Звук уведомления', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + for (final s in _sounds) + ListTile( + onTap: () => Navigator.of(context).pop(s), + leading: Icon( + s == _selectedSound + ? Symbols.radio_button_checked + : Symbols.radio_button_unchecked, + color: s == _selectedSound + ? cs.primary + : cs.onSurfaceVariant, + ), + title: Text( + s, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + ), + ), + ), + ], + ), + ), + ); + }, + ); + if (picked != null && picked != _selectedSound) { + setState(() => _selectedSound = picked); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBarM3E( + titleText: 'Уведомления', + backgroundColor: cs.surface, + ), + body: SafeArea( + top: false, + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + children: [ + _sectionHeader(cs, 'FKM'), + _card(cs, [ + _toggleRow( + cs, + icon: Symbols.notifications_active, + label: 'Включить уведомления', + subtitle: + 'Для работы FKM уведомлений, приложению понадобится держать уведомление в шторке.', + value: _fkmEnabled, + onChanged: (v) => setState(() => _fkmEnabled = v), + ), + ]), + const SizedBox(height: 20), + _sectionHeader(cs, 'Настройки уведомлений'), + _card(cs, [ + _toggleRow( + cs, + icon: Symbols.person, + label: 'Уведомления от личных чатов', + value: _personalChatsEnabled, + onChanged: (v) => setState(() => _personalChatsEnabled = v), + ), + _divider(cs), + _toggleRow( + cs, + icon: Symbols.groups, + label: 'Уведомления от групп', + value: _groupsEnabled, + onChanged: (v) => setState(() => _groupsEnabled = v), + ), + _divider(cs), + _toggleRow( + cs, + icon: Symbols.campaign, + label: 'Уведомления от каналов', + value: _channelsEnabled, + onChanged: (v) => setState(() => _channelsEnabled = v), + ), + ]), + const SizedBox(height: 20), + _sectionHeader(cs, 'Звук'), + _card(cs, [ + _tappableRow( + cs, + icon: Symbols.music_note, + label: 'Звук уведомления', + trailingText: _selectedSound, + onTap: _pickSound, + ), + ]), + ], + ), + ), + ); + } + + Widget _sectionHeader(ColorScheme cs, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Text( + title, + style: TextStyle( + color: cs.primary, + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + ); + } + + Widget _card(ColorScheme cs, List children) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column(children: children), + ); + } + + Widget _divider(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ); + } + + Widget _toggleRow( + ColorScheme cs, { + required IconData icon, + required String label, + String? subtitle, + required bool value, + required ValueChanged onChanged, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(!value), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + height: 1.3, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ); + } + + Widget _tappableRow( + ColorScheme cs, { + required IconData icon, + required String label, + required String trailingText, + required VoidCallback onTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + trailingText, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + const SizedBox(width: 6), + Icon(Symbols.chevron_right, color: cs.outline, size: 20), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index ab6998d..84ee9b9 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -24,10 +24,16 @@ class _PasswordEntryScreenState extends State { Future _check2faStatus() async { try { - final profile = await AppDatabase.loadActiveProfile(); + bool is2faEnabled; + try { + is2faEnabled = (await accountModule.get2faStatus()).enabled; + } catch (_) { + final profile = await AppDatabase.loadActiveProfile(); + is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + } if (mounted) { setState(() { - _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _is2faEnabled = is2faEnabled; _isLoading = false; }); } @@ -307,33 +313,40 @@ class TwoFactorSetupScreen extends StatefulWidget { class _TwoFactorSetupScreenState extends State { final _passwordController = TextEditingController(); + final _confirmController = TextEditingController(); final _hintController = TextEditingController(); final _emailController = TextEditingController(); final _codeController = TextEditingController(); int _step = 0; - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _trackId; String? _errorMessage; @override void dispose() { _passwordController.dispose(); + _confirmController.dispose(); _hintController.dispose(); _emailController.dispose(); _codeController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _nextStep() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { switch (_step) { case 0: + if (_passwordController.text.length < 6) { + setState( + () => _errorMessage = 'Пароль должен быть минимум 6 символов', + ); + break; + } final trackId = await accountModule.create2faTrack(); setState(() { _trackId = trackId; @@ -341,10 +354,8 @@ class _TwoFactorSetupScreenState extends State { }); break; case 1: - if (_passwordController.text.length < 6) { - setState( - () => _errorMessage = 'Пароль должен быть минимум 6 символов', - ); + if (_confirmController.text != _passwordController.text) { + setState(() => _errorMessage = 'Пароли не совпадают'); break; } await accountModule.set2faPassword( @@ -392,7 +403,7 @@ class _TwoFactorSetupScreenState extends State { setState(() => _errorMessage = e.toString()); } finally { if (mounted) { - setState(() => _isLoading = false); + _isLoading.value = false; } } } @@ -458,26 +469,29 @@ class _TwoFactorSetupScreenState extends State { const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _nextStep, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), ), ), ], @@ -564,18 +578,9 @@ class _TwoFactorSetupScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 16), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Введите пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Введите пароль', ), ], ); @@ -599,18 +604,9 @@ class _TwoFactorSetupScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(height: 16), - TextField( - controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Повторите пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + _PasswordField( + controller: _confirmController, + hintText: 'Повторите пароль', ), ], ); @@ -732,7 +728,7 @@ class TwoFactorManageScreen extends StatefulWidget { class _TwoFactorManageScreenState extends State { final _passwordController = TextEditingController(); - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); bool _isAuthenticated = false; String? _trackId; TwoFactorDetails? _details; @@ -741,14 +737,13 @@ class _TwoFactorManageScreenState extends State { @override void dispose() { _passwordController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _authenticate() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { _trackId = await accountModule.enter2faPanel(); @@ -761,7 +756,7 @@ class _TwoFactorManageScreenState extends State { } catch (e) { setState(() => _errorMessage = 'Неверный пароль'); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -818,42 +813,36 @@ class _TwoFactorManageScreenState extends State { style: TextStyle(color: cs.onErrorContainer), ), ), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Пароль', ), const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _authenticate, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _authenticate, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Продолжить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : const Text('Продолжить'), ), ), ], @@ -944,13 +933,14 @@ class _TwoFactorPasswordChangeScreenState extends State { final _passwordController = TextEditingController(); final _hintController = TextEditingController(); - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _errorMessage; @override void dispose() { _passwordController.dispose(); _hintController.dispose(); + _isLoading.dispose(); super.dispose(); } @@ -960,10 +950,8 @@ class _TwoFactorPasswordChangeScreenState return; } - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { final trackId = await accountModule.enter2faPanel(); @@ -983,7 +971,7 @@ class _TwoFactorPasswordChangeScreenState } catch (e) { setState(() => _errorMessage = e.toString()); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -1035,18 +1023,9 @@ class _TwoFactorPasswordChangeScreenState style: TextStyle(color: cs.onErrorContainer), ), ), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Введите новый пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Введите новый пароль', ), const SizedBox(height: 24), Text( @@ -1073,26 +1052,29 @@ class _TwoFactorPasswordChangeScreenState const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _changePassword, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _changePassword, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Сохранить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : const Text('Сохранить'), ), ), ], @@ -1116,7 +1098,7 @@ class _TwoFactorEmailChangeScreenState final _emailController = TextEditingController(); final _codeController = TextEditingController(); int _step = 0; - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _trackId; String? _errorMessage; @@ -1125,14 +1107,13 @@ class _TwoFactorEmailChangeScreenState _passwordController.dispose(); _emailController.dispose(); _codeController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _nextStep() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { switch (_step) { @@ -1176,7 +1157,7 @@ class _TwoFactorEmailChangeScreenState } catch (e) { setState(() => _errorMessage = e.toString()); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -1216,18 +1197,9 @@ class _TwoFactorEmailChangeScreenState ), ), const SizedBox(height: 16), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Пароль', ), ] else ...[ if (_errorMessage != null) @@ -1301,26 +1273,29 @@ class _TwoFactorEmailChangeScreenState const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _nextStep, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), ), ), ], @@ -1339,20 +1314,19 @@ class TwoFactorRemoveScreen extends StatefulWidget { class _TwoFactorRemoveScreenState extends State { final _passwordController = TextEditingController(); - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _errorMessage; @override void dispose() { _passwordController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _remove2fa() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { final trackId = await accountModule.enter2faPanel(); @@ -1368,7 +1342,7 @@ class _TwoFactorRemoveScreenState extends State { } catch (e) { setState(() => _errorMessage = e.toString()); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -1440,42 +1414,36 @@ class _TwoFactorRemoveScreenState extends State { style: TextStyle(color: cs.onErrorContainer), ), ), - TextField( + _PasswordField( controller: _passwordController, - obscureText: true, - decoration: InputDecoration( - hintText: 'Пароль', - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), + hintText: 'Пароль', ), const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _remove2fa, - style: FilledButton.styleFrom( - backgroundColor: cs.error, - foregroundColor: cs.onError, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _remove2fa, + style: FilledButton.styleFrom( + backgroundColor: cs.error, + foregroundColor: cs.onError, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onError, + ), + ) + : const Text('Удалить пароль'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onError, - ), - ) - : const Text('Удалить пароль'), ), ), ], @@ -1484,3 +1452,45 @@ class _TwoFactorRemoveScreenState extends State { ); } } + +class _PasswordField extends StatefulWidget { + final TextEditingController controller; + final String hintText; + + const _PasswordField({ + required this.controller, + required this.hintText, + }); + + @override + State<_PasswordField> createState() => _PasswordFieldState(); +} + +class _PasswordFieldState extends State<_PasswordField> { + bool _visible = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return TextField( + controller: widget.controller, + obscureText: !_visible, + decoration: InputDecoration( + hintText: widget.hintText, + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + suffixIcon: IconButton( + icon: Icon( + _visible ? Symbols.visibility_off : Symbols.visibility, + color: cs.onSurfaceVariant, + ), + onPressed: () => setState(() => _visible = !_visible), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index a33306c..680234b 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -47,12 +47,18 @@ class _SecurityScreenState extends State accountModule.getBlockedContacts(), AppDatabase.loadActiveProfile(), ]); + bool is2faEnabled; + try { + is2faEnabled = (await accountModule.get2faStatus()).enabled; + } catch (_) { + final profile = results[2] as ProfileData?; + is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + } if (mounted) { setState(() { _privacyConfig = results[0] as PrivacyConfig; _blockedContacts = results[1] as List; - final profile = results[2] as ProfileData?; - _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _is2faEnabled = is2faEnabled; _isLoading = false; }); } diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index dd40169..d8c7ba1 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import '../../../backend/modules/chats.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; @@ -20,6 +21,7 @@ import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'edit_profile_screen.dart'; import 'info_screen.dart'; +import 'notifications_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -206,6 +208,7 @@ class _SettingsTabState extends State { } ContactCache.clear(); TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); try { await api.connect(); } catch (_) {} @@ -309,9 +312,17 @@ child: _buildSection( context, cs, items: [ - const _SettingsItem( + _SettingsItem( icon: Symbols.notifications_active, - label: 'Уведомления и звук', + label: 'Уведомления', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const NotificationsScreen(), + ), + ); + }, ), _SettingsItem( icon: Symbols.vibration, diff --git a/lib/frontend/widgets/login_success_screen.dart b/lib/frontend/widgets/login_success_screen.dart index 9617e37..e8d8675 100644 --- a/lib/frontend/widgets/login_success_screen.dart +++ b/lib/frontend/widgets/login_success_screen.dart @@ -22,8 +22,9 @@ Future precacheLoginAvatar( class LoginSuccessScreen extends StatefulWidget { final ImageProvider? avatar; + final bool preview; - const LoginSuccessScreen({super.key, this.avatar}); + const LoginSuccessScreen({super.key, this.avatar, this.preview = false}); @override State createState() => _LoginSuccessScreenState(); @@ -114,6 +115,10 @@ class _LoginSuccessScreenState extends State void _onStatus(AnimationStatus status) { if (status == AnimationStatus.completed && !_navigated && mounted) { _navigated = true; + if (widget.preview) { + Navigator.of(context).pop(); + return; + } Navigator.of(context).pushAndRemoveUntil( PageRouteBuilder( transitionDuration: const Duration(milliseconds: 360), diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index dfe82b3..a8f5ef9 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,4 +1,5 @@ import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -7,7 +8,14 @@ import '../../core/config/app_bubble_behavior.dart'; import '../../core/config/app_bubble_shape.dart'; import '../../core/utils/bubble_radius.dart'; import '../../core/utils/haptics.dart'; +import '../../core/utils/file_download.dart'; +import '../../core/utils/media_cache.dart'; +import '../../core/utils/download_progress.dart'; +import 'custom_notification.dart'; import '../../models/attachment.dart'; +import 'poll_view.dart'; +import 'photo_viewer.dart'; +import 'video_player_screen.dart'; enum MessageType { text, attachment, voice, control } @@ -22,6 +30,7 @@ class _BubbleCtx { final MessageType contentType; final bool hasPhotoWithCaption; final bool hasMultiplePhotosNoCaption; + final Map? reactionInfo; _BubbleCtx({ required this.context, @@ -31,6 +40,7 @@ class _BubbleCtx { required this.contentType, required this.hasPhotoWithCaption, required this.hasMultiplePhotosNoCaption, + this.reactionInfo, }) : dim = text.withValues(alpha: 0.7); } @@ -47,6 +57,10 @@ class MessageBubble extends StatelessWidget { static const Radius _smallRadius = Radius.circular(4); static const Radius _photoRadius = Radius.circular(photoBorderRadius); + static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); + static const BorderRadius _reactionChipRadius = + BorderRadius.all(Radius.circular(10)); + static Color bubbleTextColor(BuildContext context) => Theme.of(context).brightness == Brightness.dark ? Colors.white @@ -59,6 +73,7 @@ class MessageBubble extends StatelessWidget { final CachedMessage? nextMessage; final String chatType; final String? overrideStatus; + final ValueListenable?>? reactionsListenable; const MessageBubble({ super.key, @@ -69,6 +84,7 @@ class MessageBubble extends StatelessWidget { this.nextMessage, required this.chatType, this.overrideStatus, + this.reactionsListenable, }); bool _computeHasPhotoWithCaption() { @@ -259,16 +275,6 @@ class MessageBubble extends StatelessWidget { final hasMultiPhotos = _computeHasMultiplePhotosNoCaption(); final textColor = bubbleTextColor(context); - final ctx = _BubbleCtx( - context: context, - cs: cs, - text: textColor, - shape: shape, - contentType: contentType, - hasPhotoWithCaption: hasPhotoCap, - hasMultiplePhotosNoCaption: hasMultiPhotos, - ); - final topMargin = _topMarginFor(contentType, shape); final bottomMargin = _bottomMarginFor(contentType, shape); final padding = _paddingFor(contentType, shape); @@ -276,8 +282,34 @@ class MessageBubble extends StatelessWidget { final showAvatarSlot = !isMe; final showAvatar = showAvatarSlot && chatType == "CHAT" && - nextMessage?.senderId != message.senderId && - prevMessage?.senderId == message.senderId; + nextMessage?.senderId != message.senderId; + + final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75; + final bubbleColor = + isMe ? cs.primaryContainer : cs.surfaceContainerHighest; + + _BubbleCtx makeCtx() => _BubbleCtx( + context: context, + cs: cs, + text: textColor, + shape: shape, + contentType: contentType, + hasPhotoWithCaption: hasPhotoCap, + hasMultiplePhotosNoCaption: hasMultiPhotos, + reactionInfo: _resolveReactionInfo(), + ); + + final Widget bubbleContent = + reactionsListenable != null && contentType == MessageType.text + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, _, _) => _buildContent(makeCtx()), + ) + : _buildContent(makeCtx()); + + final reactionsUnder = _reactionsUnderBubble(contentType); + final reactionsInside = + contentType != MessageType.text && !reactionsUnder; return GestureDetector( onTap: Haptics.tap, @@ -304,32 +336,40 @@ class MessageBubble extends StatelessWidget { radius: 15, backgroundColor: Color(0x00000000), ), - ListenableBuilder( - listenable: Listenable.merge( - [AppBubbleShape.current, AppBubbleBehavior.current], - ), - builder: (context, child) { - return Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.75, - ), - decoration: BoxDecoration( - color: isMe - ? cs.primaryContainer - : cs.surfaceContainerHighest, - borderRadius: _borderRadiusFor( - AppBubbleShape.current.value, - AppBubbleBehavior.current.value, - shape, - hasPhotoCap, - hasMultiPhotos, + Column( + crossAxisAlignment: + isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + ListenableBuilder( + listenable: Listenable.merge([ + AppBubbleShape.current, + AppBubbleBehavior.current, + ]), + builder: (context, child) => Container( + constraints: BoxConstraints(maxWidth: maxBubbleWidth), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: _borderRadiusFor( + AppBubbleShape.current.value, + AppBubbleBehavior.current.value, + shape, + hasPhotoCap, + hasMultiPhotos, + ), ), + padding: padding, + child: child, ), - padding: padding, - child: child, - ); - }, - child: _buildContent(ctx), + child: reactionsInside + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [bubbleContent, _reactionsBar(cs)], + ) + : bubbleContent, + ), + if (reactionsUnder) _reactionsBar(cs), + ], ), ], ), @@ -338,6 +378,37 @@ class MessageBubble extends StatelessWidget { ); } + Map? _resolveReactionInfo() { + if (reactionsListenable != null) { + final v = reactionsListenable!.value; + if (v != null) return v; + } + final info = message.payload?['reactionInfo']; + if (info is Map) return info; + return null; + } + + bool _reactionsUnderBubble(MessageType contentType) { + if (contentType != MessageType.attachment) return false; + final attachments = message.attachments; + if (attachments == null || attachments.isEmpty) return false; + if (attachments.first is ForwardedMessageAttachment) return false; + if (attachments.any((a) => a is ContactAttachment)) return false; + if (attachments.whereType().length >= 2) return false; + return true; + } + + Widget _reactionsBar(ColorScheme cs) { + final listenable = reactionsListenable; + if (listenable != null) { + return ValueListenableBuilder?>( + valueListenable: listenable, + builder: (context, info, _) => _buildReactionsBarFor(cs, info), + ); + } + return _buildReactionsBar(cs); + } + Widget _buildContent(_BubbleCtx ctx) { switch (ctx.contentType) { case MessageType.control: @@ -351,6 +422,65 @@ class MessageBubble extends StatelessWidget { } } + Widget _buildReactionsBar(ColorScheme cs) { + final info = message.payload?['reactionInfo']; + return _buildReactionsBarFor(cs, info is Map ? info : null); + } + + Widget _buildReactionsBarFor(ColorScheme cs, Map? info) { + final chips = _buildReactionChipsFor(cs, info); + if (chips.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Wrap(spacing: 4, runSpacing: 4, children: chips), + ); + } + + List _buildReactionChipsFor(ColorScheme cs, Map? info) { + if (info == null) return const []; + final counters = info['counters']; + if (counters is! List || counters.isEmpty) return const []; + final yourReaction = info['yourReaction']?.toString(); + + final chips = []; + for (final c in counters) { + if (c is! Map) continue; + final reaction = c['reaction']?.toString(); + final count = c['count']; + if (reaction == null || reaction.isEmpty) continue; + final isYours = yourReaction == reaction; + chips.add( + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isYours + ? cs.primary.withValues(alpha: 0.22) + : _reactionChipBg, + borderRadius: _reactionChipRadius, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(reaction, style: const TextStyle(fontSize: 13)), + if (count is int && count > 1) ...[ + const SizedBox(width: 3), + Text( + count.toString(), + style: TextStyle( + color: isYours ? cs.primary : cs.onSurfaceVariant, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ); + } + return chips; + } + Widget _buildControlContent(ColorScheme cs) { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { @@ -421,49 +551,95 @@ class MessageBubble extends StatelessWidget { final displaySender = ContactCache.get(message.senderId); + final reactionChips = _buildReactionChipsFor(ctx.cs, ctx.reactionInfo); + final hasReactions = reactionChips.isNotEmpty; + + final textWidget = isForwarded + ? _buildForwardedInlineText(ctx, forwarded) + : Text( + message.text ?? '', + style: TextStyle( + color: ctx.text, + fontSize: 16, + height: 1.3, + ), + ); + + final metaWidget = Text( + message.status == 'EDITED' + ? '${_formatTime(message.time)} ред.' + : _formatTime(message.time), + style: TextStyle(color: ctx.dim, fontSize: 10), + ); + + final showSender = message.senderId != message.accountId && + prevMessage?.senderId != message.senderId && + chatType == "CHAT"; + + if (hasReactions) { + return IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showSender) + Text( + displaySender ?? "", + textAlign: TextAlign.left, + style: TextStyle(color: ctx.text), + ), + textWidget, + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Wrap( + spacing: 4, + runSpacing: 4, + children: reactionChips, + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: metaWidget, + ), + if (isMe) ...[ + const SizedBox(width: 4), + _buildStatusIcon(ctx), + ], + ], + ), + ], + ), + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (message.senderId != message.accountId && - prevMessage?.senderId != message.senderId && - chatType == "CHAT") + if (showSender) Text( displaySender ?? "", textAlign: TextAlign.left, style: TextStyle(color: ctx.text), ), Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible( - child: isForwarded - ? _buildForwardedInlineText(ctx, forwarded) - : Text( - message.text ?? '', - style: TextStyle( - color: ctx.text, - fontSize: 16, - height: 1.3, - ), - ), - ), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - message.status == 'EDITED' - ? '${_formatTime(message.time)} ред.' - : _formatTime(message.time), - style: TextStyle(color: ctx.dim, fontSize: 10), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible(child: textWidget), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: metaWidget, ), - ), - if (isMe) ...[ - const SizedBox(width: 4), - _buildStatusIcon(ctx), + if (isMe) ...[ + const SizedBox(width: 4), + _buildStatusIcon(ctx), + ], ], - ], - ), + ), ], ); } @@ -576,6 +752,11 @@ class MessageBubble extends StatelessWidget { return _buildContactAttachment(ctx, contacts.first); } + final polls = attachments.whereType().toList(); + if (polls.isNotEmpty) { + return _buildPollAttachment(ctx, polls.first); + } + final photos = attachments.whereType().toList(); if (photos.isEmpty) { return _buildGenericAttachment(ctx, attachments.first); @@ -584,6 +765,21 @@ class MessageBubble extends StatelessWidget { return _buildPhotoContent(ctx, photos); } + Widget _buildPollAttachment(_BubbleCtx ctx, PollAttachment poll) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: PollView( + chatId: message.chatId, + messageId: message.id, + pollId: poll.pollId, + fallbackTitle: poll.title ?? message.text, + textColor: ctx.text, + dimColor: ctx.dim, + accentColor: ctx.text, + ), + ); + } + Widget _buildPhotoContent(_BubbleCtx ctx, List photos) { final hasCaption = message.text != null && message.text!.isNotEmpty; final count = photos.length; @@ -805,6 +1001,7 @@ class MessageBubble extends StatelessWidget { final constrainedWidth = width.clamp(photoMinSize, photoMaxSize); final constrainedHeight = height.clamp(photoMinSize, photoMaxSize); + final dpr = MediaQuery.of(ctx.context).devicePixelRatio; final matchTop = ctx.hasPhotoWithCaption; final matchBottom = !ctx.hasPhotoWithCaption; @@ -830,8 +1027,8 @@ class MessageBubble extends StatelessWidget { width: constrainedWidth, height: constrainedHeight, fit: BoxFit.cover, - memCacheWidth: (constrainedWidth * 2).round(), - memCacheHeight: (constrainedHeight * 2).round(), + memCacheWidth: (constrainedWidth * dpr).round(), + memCacheHeight: (constrainedHeight * dpr).round(), fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => _buildPhotoPlaceholder( ctx.cs, @@ -926,6 +1123,8 @@ class MessageBubble extends StatelessWidget { Widget _buildPhotoTile(_BubbleCtx ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; + final cachePx = + (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round(); return AspectRatio( aspectRatio: 1, child: Stack( @@ -936,8 +1135,8 @@ class MessageBubble extends StatelessWidget { fit: BoxFit.cover, width: double.infinity, height: double.infinity, - memCacheWidth: 280, - memCacheHeight: 280, + memCacheWidth: cachePx, + memCacheHeight: cachePx, fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, 100, 100), @@ -961,6 +1160,8 @@ class MessageBubble extends StatelessWidget { String overlay, ) { final imageUrl = photo.baseUrl ?? ''; + final cachePx = + (photoMaxSize * MediaQuery.of(ctx.context).devicePixelRatio).round(); return AspectRatio( aspectRatio: 1, child: Stack( @@ -971,8 +1172,8 @@ class MessageBubble extends StatelessWidget { fit: BoxFit.cover, width: double.infinity, height: double.infinity, - memCacheWidth: 280, - memCacheHeight: 280, + memCacheWidth: cachePx, + memCacheHeight: cachePx, fadeInDuration: const Duration(milliseconds: 120), errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, 100, 100), @@ -1061,10 +1262,22 @@ class MessageBubble extends StatelessWidget { color: ctx.cs.onSurfaceVariant, ), ), + Center( + child: Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon(Symbols.play_arrow, + color: Colors.white, size: 30), + ), + ), Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () {}, + onTap: () => _playVideo(ctx.context, video), ), ), ], @@ -1076,10 +1289,55 @@ class MessageBubble extends StatelessWidget { ); } + Future _playVideo( + BuildContext context, + MessageAttachment video, + ) async { + final videoId = (video as dynamic).videoId as int?; + final token = (video as dynamic).videoToken as String?; + if (videoId == null) { + showCustomNotification(context, 'Не удалось открыть видео'); + return; + } + Haptics.tap(); + + final cacheName = 'video_$videoId.mp4'; + final cached = await MediaCache.existing(cacheName) != null; + if (!context.mounted) return; + + String? url; + if (!cached) { + if (token == null) { + showCustomNotification(context, 'Не удалось открыть видео'); + return; + } + url = await messagesModule.getVideoUrl( + messageId: message.id, + chatId: message.chatId, + token: token, + videoId: videoId, + ); + if (!context.mounted) return; + if (url == null) { + showCustomNotification(context, 'Не удалось получить видео'); + return; + } + } + + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => VideoPlayerScreen(cacheName: cacheName, url: url), + ), + ); + } + Widget _buildFileAttachment(_BubbleCtx ctx, MessageAttachment file) { final name = (file as dynamic).name as String? ?? 'File'; final size = (file as dynamic).size as int? ?? 0; final sizeStr = _formatFileSize(size); + final fileId = (file as dynamic).fileId as int?; + final cacheName = '${fileId}_$name'; return IntrinsicWidth( child: Padding( @@ -1125,35 +1383,61 @@ class MessageBubble extends StatelessWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - Text( - sizeStr, - style: TextStyle( - color: ctx.dim, - fontSize: 12, - height: 1.2, + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) => Text( + progress != null + ? '${(progress * 100).round()}% · $sizeStr' + : sizeStr, + style: TextStyle( + color: ctx.dim, + fontSize: 12, + height: 1.2, + ), ), ), ], ), ), const SizedBox(width: 12), - GestureDetector( - onTap: () {}, - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: isMe - ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) - : ctx.cs.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - Symbols.download, - color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary, - size: 18, - ), - ), + ValueListenableBuilder( + valueListenable: MediaDownloadProgress.notifier(cacheName), + builder: (context, progress, _) { + final downloading = progress != null; + return GestureDetector( + onTap: downloading + ? null + : () => _downloadFile(ctx.context, file, name), + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? ctx.cs.onPrimaryContainer.withValues(alpha: 0.12) + : ctx.cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: downloading + ? Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress > 0 ? progress : null, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + ), + ) + : Icon( + Symbols.download, + color: isMe + ? ctx.cs.onPrimaryContainer + : ctx.cs.primary, + size: 18, + ), + ), + ); + }, ), ], ), @@ -1436,7 +1720,48 @@ class MessageBubble extends StatelessWidget { } void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) { - // TODO: Open photo viewer + final url = photo.baseUrl ?? ''; + if (url.isEmpty) return; + Navigator.of(ctx).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => PhotoViewerScreen(baseUrl: url), + ), + ); + } + + Future _downloadFile( + BuildContext context, + MessageAttachment file, + String name, + ) async { + final fileId = (file as dynamic).fileId as int?; + if (fileId == null) { + showCustomNotification(context, 'Не удалось определить файл'); + return; + } + Haptics.tap(); + + final cacheName = '${fileId}_$name'; + + MediaDownloadProgress.set(cacheName, 0); + final result = await openCachedFile( + cacheName, + () => messagesModule.getFileUrl( + messageId: message.id, + chatId: message.chatId, + fileId: fileId, + ), + onProgress: (p) => MediaDownloadProgress.set(cacheName, p), + ); + MediaDownloadProgress.set(cacheName, null); + if (!context.mounted) return; + if (!result.ok) { + showCustomNotification( + context, + 'Ошибка загрузки: ${result.error ?? 'не удалось открыть'}', + ); + } } Widget _buildVoiceContent(_BubbleCtx ctx) { @@ -1599,7 +1924,7 @@ class _VoiceMessageBubble extends StatefulWidget { class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { bool _isPlaying = false; - double _progress = 0.0; + final ValueNotifier _progress = ValueNotifier(0.0); bool _transcriptionVisible = false; String? _transcriptionText; bool _transcriptionLoading = false; @@ -1607,10 +1932,13 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { @override void initState() { super.initState(); - if (widget.preloadedText != null) { - _transcriptionText = widget.preloadedText; - _transcriptionVisible = true; - } + _transcriptionText = widget.preloadedText; + } + + @override + void dispose() { + _progress.dispose(); + super.dispose(); } String _formatDuration(int seconds) { @@ -1705,18 +2033,14 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { builder: (context, constraints) { return GestureDetector( onTapDown: (details) { - setState(() { - _progress = (details.localPosition.dx / - constraints.maxWidth) - .clamp(0.0, 1.0); - }); + _progress.value = + (details.localPosition.dx / constraints.maxWidth) + .clamp(0.0, 1.0); }, onHorizontalDragUpdate: (details) { - setState(() { - _progress = (details.localPosition.dx / - constraints.maxWidth) - .clamp(0.0, 1.0); - }); + _progress.value = + (details.localPosition.dx / constraints.maxWidth) + .clamp(0.0, 1.0); }, child: Container( height: 4, @@ -1724,13 +2048,16 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { color: waveInactiveColor, borderRadius: BorderRadius.circular(2), ), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: _progress.clamp(0.0, 1.0), - child: Container( - decoration: BoxDecoration( - color: waveActiveColor, - borderRadius: BorderRadius.circular(2), + child: ValueListenableBuilder( + valueListenable: _progress, + builder: (context, progress, _) => FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: waveActiveColor, + borderRadius: BorderRadius.circular(2), + ), ), ), ), @@ -1772,14 +2099,19 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - _formatDuration(widget.duration), - style: TextStyle( - color: widget.textColor.withValues(alpha: 0.7), - fontSize: 11, + SizedBox( + width: 32, + child: Center( + child: Text( + _formatDuration(widget.duration), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.7), + fontSize: 11, + ), + ), ), ), - const SizedBox(width: 8), + const SizedBox(width: 10), Expanded( child: AnimatedSize( duration: const Duration(milliseconds: 200), diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart new file mode 100644 index 0000000..8cfc89e --- /dev/null +++ b/lib/frontend/widgets/photo_viewer.dart @@ -0,0 +1,54 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class PhotoViewerScreen extends StatelessWidget { + final String baseUrl; + + const PhotoViewerScreen({super.key, required this.baseUrl}); + + String get _url => baseUrl; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned.fill( + child: InteractiveViewer( + minScale: 1, + maxScale: 5, + child: Center( + child: _url.isEmpty + ? const Icon(Symbols.broken_image, + color: Colors.white54, size: 64) + : CachedNetworkImage( + imageUrl: _url, + fit: BoxFit.contain, + fadeInDuration: const Duration(milliseconds: 120), + placeholder: (_, _) => const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + errorWidget: (_, _, _) => const Icon( + Symbols.broken_image, + color: Colors.white54, + size: 64, + ), + ), + ), + ), + ), + Positioned( + top: MediaQuery.of(context).padding.top + 8, + left: 8, + child: IconButton( + icon: const Icon(Symbols.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/widgets/poll_view.dart b/lib/frontend/widgets/poll_view.dart new file mode 100644 index 0000000..5a40436 --- /dev/null +++ b/lib/frontend/widgets/poll_view.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../../main.dart'; +import '../../models/poll.dart'; + +class PollView extends StatefulWidget { + final int chatId; + final String messageId; + final int pollId; + final String? fallbackTitle; + final Color textColor; + final Color dimColor; + final Color accentColor; + + const PollView({ + super.key, + required this.chatId, + required this.messageId, + required this.pollId, + required this.textColor, + required this.dimColor, + required this.accentColor, + this.fallbackTitle, + }); + + @override + State createState() => _PollViewState(); +} + +class _PollViewState extends State { + @override + void initState() { + super.initState(); + pollsModule.fetch(widget.chatId, widget.messageId, widget.pollId); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: pollsModule, + builder: (context, _) { + final poll = pollsModule.get(widget.pollId); + return _buildCard(poll); + }, + ); + } + + Widget _buildCard(Poll? poll) { + final title = poll?.title.isNotEmpty == true + ? poll!.title + : (widget.fallbackTitle ?? 'Опрос'); + + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 220, maxWidth: 280), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + color: widget.textColor, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + poll == null + ? 'Загрузка опроса…' + : _votesLabel(poll.total), + style: TextStyle(color: widget.dimColor, fontSize: 12), + ), + const SizedBox(height: 10), + if (poll != null) + ...poll.answers.map((a) => _buildAnswer(a, poll.total)), + ], + ), + ); + } + + Widget _buildAnswer(PollAnswer answer, int total) { + final pct = total > 0 ? answer.voteCount / total : 0.0; + final pctLabel = '${(pct * 100).round()}%'; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + answer.text, + style: TextStyle(color: widget.textColor, fontSize: 14), + ), + ), + const SizedBox(width: 8), + Text( + pctLabel, + style: TextStyle( + color: widget.dimColor, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: pct, + minHeight: 6, + backgroundColor: widget.dimColor.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(widget.accentColor), + ), + ), + ], + ), + ); + } + + String _votesLabel(int total) { + if (total == 0) return 'Нет голосов'; + final mod10 = total % 10; + final mod100 = total % 100; + String word; + if (mod10 == 1 && mod100 != 11) { + word = 'голос'; + } else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) { + word = 'голоса'; + } else { + word = 'голосов'; + } + return '$total $word'; + } +} diff --git a/lib/frontend/widgets/rightward_drag_recognizer.dart b/lib/frontend/widgets/rightward_drag_recognizer.dart new file mode 100644 index 0000000..c63fcbb --- /dev/null +++ b/lib/frontend/widgets/rightward_drag_recognizer.dart @@ -0,0 +1,81 @@ +import 'package:flutter/gestures.dart'; + +class RightwardDragRecognizer extends HorizontalDragGestureRecognizer { + RightwardDragRecognizer({super.debugOwner}) { + onlyAcceptDragOnThreshold = true; + } + + static const double _kMinAcceptVelocity = 700.0; + static const double _kMinAcceptDistance = 20.0; + + final Map _initialPositions = {}; + final Map _velocityTrackers = {}; + final Map _currentDeltaX = {}; + + @override + void addAllowedPointer(PointerDownEvent event) { + _initialPositions[event.pointer] = event.position; + final tracker = VelocityTracker.withKind(event.kind); + tracker.addPosition(event.timeStamp, event.localPosition); + _velocityTrackers[event.pointer] = tracker; + super.addAllowedPointer(event); + } + + @override + void handleEvent(PointerEvent event) { + if (event is PointerMoveEvent) { + _velocityTrackers[event.pointer] + ?.addPosition(event.timeStamp, event.localPosition); + final initial = _initialPositions[event.pointer]; + if (initial != null) { + final dx = event.position.dx - initial.dx; + _currentDeltaX[event.pointer] = dx; + if (dx < -kTouchSlop) { + stopTrackingPointer(event.pointer); + _cleanup(event.pointer); + return; + } + } + } + super.handleEvent(event); + } + + @override + bool hasSufficientGlobalDistanceToAccept( + PointerDeviceKind pointerDeviceKind, + double? deviceTouchSlop, + ) { + if (!super.hasSufficientGlobalDistanceToAccept( + pointerDeviceKind, deviceTouchSlop)) { + return false; + } + double maxDx = 0; + for (final dx in _currentDeltaX.values) { + if (dx > maxDx) maxDx = dx; + } + if (maxDx < _kMinAcceptDistance) return false; + for (final tracker in _velocityTrackers.values) { + final vx = tracker.getVelocity().pixelsPerSecond.dx; + if (vx >= _kMinAcceptVelocity) return true; + } + return false; + } + + void _cleanup(int pointer) { + _initialPositions.remove(pointer); + _velocityTrackers.remove(pointer); + _currentDeltaX.remove(pointer); + } + + @override + void didStopTrackingLastPointer(int pointer) { + _cleanup(pointer); + super.didStopTrackingLastPointer(pointer); + } + + @override + void rejectGesture(int pointer) { + _cleanup(pointer); + super.rejectGesture(pointer); + } +} diff --git a/lib/frontend/widgets/swipe_route.dart b/lib/frontend/widgets/swipe_route.dart index 933ad50..2927d9f 100644 --- a/lib/frontend/widgets/swipe_route.dart +++ b/lib/frontend/widgets/swipe_route.dart @@ -1,12 +1,101 @@ +import 'dart:math' as math; +import 'dart:ui'; + import 'package:flutter/cupertino.dart'; -class SwipeRoute extends CupertinoPageRoute { +import 'rightward_drag_recognizer.dart'; + +class SwipeRoute extends PageRoute { SwipeRoute({ - required super.builder, + required this.builder, super.settings, - super.maintainState, super.fullscreenDialog, + this.maintainState = true, }); + + final WidgetBuilder builder; + + @override + final bool maintainState; + + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + Duration get transitionDuration => const Duration(milliseconds: 400); + + @override + Duration get reverseTransitionDuration => const Duration(milliseconds: 400); + + @override + bool canTransitionTo(TransitionRoute nextRoute) { + return nextRoute is SwipeRoute || nextRoute is CupertinoRouteTransitionMixin; + } + + @override + bool get popGestureInProgress => _gestureController != null; + + _SwipeBackController? _gestureController; + + @override + bool get popGestureEnabled { + if (isFirst) return false; + if (willHandlePopInternally) return false; + if (popDisposition == RoutePopDisposition.doNotPop) return false; + if (animation?.status != AnimationStatus.completed) return false; + if (secondaryAnimation?.status != AnimationStatus.dismissed) return false; + if (popGestureInProgress) return false; + return true; + } + + _SwipeBackController _startPopGesture() { + final gesture = _SwipeBackController( + navigator: navigator!, + controller: controller!, + ); + _gestureController = gesture; + gesture._onEnd = () { + if (_gestureController == gesture) { + _gestureController = null; + } + }; + return gesture; + } + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: builder(context), + ); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return _SwipeBackGestureDetector( + enabledCallback: () => popGestureEnabled, + onStartPopGesture: _startPopGesture, + child: CupertinoPageTransition( + primaryRouteAnimation: animation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: popGestureInProgress, + child: child, + ), + ); + } } Future pushSwipeable( @@ -18,3 +107,130 @@ Future pushSwipeable( SwipeRoute(builder: builder, settings: settings), ); } + +class _SwipeBackGestureDetector extends StatefulWidget { + const _SwipeBackGestureDetector({ + required this.enabledCallback, + required this.onStartPopGesture, + required this.child, + }); + + final ValueGetter enabledCallback; + final ValueGetter<_SwipeBackController> onStartPopGesture; + final Widget child; + + @override + State<_SwipeBackGestureDetector> createState() => + _SwipeBackGestureDetectorState(); +} + +class _SwipeBackGestureDetectorState + extends State<_SwipeBackGestureDetector> { + _SwipeBackController? _backController; + double _width = 0; + + void _handleStart(DragStartDetails details) { + if (!widget.enabledCallback()) return; + _width = context.size?.width ?? MediaQuery.of(context).size.width; + if (_width <= 0) _width = 1.0; + _backController = widget.onStartPopGesture(); + } + + void _handleUpdate(DragUpdateDetails details) { + final delta = details.primaryDelta ?? 0.0; + _backController?.dragUpdate(delta / _width); + } + + void _handleEnd(DragEndDetails details) { + final velocity = details.velocity.pixelsPerSecond.dx / _width; + _backController?.dragEnd(velocity); + _backController = null; + } + + void _handleCancel() { + _backController?.dragEnd(0.0); + _backController = null; + } + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + RightwardDragRecognizer: + GestureRecognizerFactoryWithHandlers( + () => RightwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _handleStart + ..onUpdate = _handleUpdate + ..onEnd = _handleEnd + ..onCancel = _handleCancel; + }, + ), + }, + child: widget.child, + ); + } +} + +class _SwipeBackController { + _SwipeBackController({ + required this.navigator, + required this.controller, + }); + + final NavigatorState navigator; + final AnimationController controller; + VoidCallback? _onEnd; + + static const double _kMinFlingVelocity = 1.0; + + void dragUpdate(double delta) { + controller.value -= delta; + } + + void dragEnd(double velocity) { + const animationCurve = Curves.fastLinearToSlowEaseIn; + final bool animateForward; + + if (velocity.abs() >= _kMinFlingVelocity) { + animateForward = velocity <= 0; + } else { + animateForward = controller.value > 0.5; + } + + if (animateForward) { + final forwardMs = math.min( + lerpDouble(800, 0, controller.value)!.floor(), + 300, + ); + controller.animateTo( + 1.0, + duration: Duration(milliseconds: forwardMs), + curve: animationCurve, + ); + } else { + navigator.pop(); + if (controller.isAnimating) { + final backMs = lerpDouble(0, 800, controller.value)!.floor(); + controller.animateBack( + 0.0, + duration: Duration(milliseconds: backMs), + curve: animationCurve, + ); + } + } + + if (controller.isAnimating) { + late AnimationStatusListener statusCb; + statusCb = (status) { + _onEnd?.call(); + controller.removeStatusListener(statusCb); + }; + controller.addStatusListener(statusCb); + } else { + _onEnd?.call(); + } + } +} diff --git a/lib/frontend/widgets/swipe_to_pop.dart b/lib/frontend/widgets/swipe_to_pop.dart index 3c14093..18ce6ff 100644 --- a/lib/frontend/widgets/swipe_to_pop.dart +++ b/lib/frontend/widgets/swipe_to_pop.dart @@ -1,22 +1,19 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'rightward_drag_recognizer.dart'; + class SwipeToPop extends StatefulWidget { final Widget child; - final double edgeWidth; final double popThreshold; final double velocityThreshold; - final bool fullWidth; final bool enabled; final VoidCallback? onPop; const SwipeToPop({ super.key, required this.child, - this.edgeWidth = 28, this.popThreshold = 0.35, this.velocityThreshold = 700, - this.fullWidth = false, this.enabled = true, this.onPop, }); @@ -28,6 +25,7 @@ class SwipeToPop extends StatefulWidget { class _SwipeToPopState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; + double _width = 0; @override void initState() { @@ -53,17 +51,19 @@ class _SwipeToPopState extends State } void _onDragStart(DragStartDetails _) { + _width = context.size?.width ?? MediaQuery.of(context).size.width; + if (_width <= 0) _width = 1.0; _controller.stop(); } - void _onDragUpdate(DragUpdateDetails d, double width) { - if (width <= 0) return; - final next = (_controller.value + d.delta.dx / width).clamp(0.0, 1.0); + void _onDragUpdate(DragUpdateDetails d) { + final next = + (_controller.value + (d.primaryDelta ?? 0.0) / _width).clamp(0.0, 1.0); _controller.value = next; } - Future _onDragEnd(DragEndDetails d, double width) async { - final velocity = d.primaryVelocity ?? 0; + Future _onDragEnd(DragEndDetails d) async { + final velocity = d.velocity.pixelsPerSecond.dx; final pastThreshold = _controller.value > widget.popThreshold || velocity > widget.velocityThreshold; if (pastThreshold) { @@ -96,44 +96,35 @@ class _SwipeToPopState extends State return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; - final gestureChild = GestureDetector( + return RawGestureDetector( behavior: HitTestBehavior.translucent, - dragStartBehavior: DragStartBehavior.down, - onHorizontalDragStart: _onDragStart, - onHorizontalDragUpdate: (d) => _onDragUpdate(d, width), - onHorizontalDragEnd: (d) => _onDragEnd(d, width), - onHorizontalDragCancel: _onDragCancel, - ); - - return Stack( - children: [ - Positioned.fill( - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) { - final t = _controller.value; - return Transform.translate( - offset: Offset(t * width, 0), - child: Opacity( - opacity: (1.0 - t * 0.35).clamp(0.0, 1.0), - child: child, - ), - ); - }, - child: widget.child, - ), + gestures: { + RightwardDragRecognizer: + GestureRecognizerFactoryWithHandlers( + () => RightwardDragRecognizer(debugOwner: this), + (instance) { + instance + ..onStart = _onDragStart + ..onUpdate = _onDragUpdate + ..onEnd = _onDragEnd + ..onCancel = _onDragCancel; + }, ), - if (widget.fullWidth) - Positioned.fill(child: gestureChild) - else - Positioned( - left: 0, - top: 0, - bottom: 0, - width: widget.edgeWidth, - child: gestureChild, - ), - ], + }, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; + return Transform.translate( + offset: Offset(t * width, 0), + child: Opacity( + opacity: (1.0 - t * 0.35).clamp(0.0, 1.0), + child: child, + ), + ); + }, + child: widget.child, + ), ); }, ); diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart new file mode 100644 index 0000000..66c48ac --- /dev/null +++ b/lib/frontend/widgets/video_player_screen.dart @@ -0,0 +1,165 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:video_player/video_player.dart'; + +import '../../core/utils/media_cache.dart'; + +class VideoPlayerScreen extends StatefulWidget { + final String cacheName; + final String? url; + + const VideoPlayerScreen({ + super.key, + required this.cacheName, + this.url, + }); + + @override + State createState() => _VideoPlayerScreenState(); +} + +class _VideoPlayerScreenState extends State { + VideoPlayerController? _controller; + bool _error = false; + double _progress = 0; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + File? file = await MediaCache.existing(widget.cacheName); + if (file == null && widget.url != null) { + file = await MediaCache.getOrDownload( + widget.cacheName, + widget.url!, + onProgress: (p) { + if (mounted) setState(() => _progress = p); + }, + ); + } + if (!mounted) return; + if (file == null) { + setState(() => _error = true); + return; + } + + final controller = VideoPlayerController.file(file); + _controller = controller; + try { + await controller.initialize(); + if (!mounted) return; + setState(() {}); + controller.play(); + controller.addListener(_onTick); + } catch (_) { + if (mounted) setState(() => _error = true); + } + } + + void _onTick() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } + + void _togglePlay() { + final c = _controller; + if (c == null || !c.value.isInitialized) return; + setState(() => c.value.isPlaying ? c.pause() : c.play()); + } + + @override + Widget build(BuildContext context) { + final c = _controller; + final ready = c != null && c.value.isInitialized; + + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Center( + child: _error + ? const Icon(Symbols.error, color: Colors.white54, size: 64) + : ready + ? AspectRatio( + aspectRatio: c.value.aspectRatio, + child: VideoPlayer(c), + ) + : _buildLoading(), + ), + if (ready) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _togglePlay, + child: AnimatedOpacity( + opacity: c.value.isPlaying ? 0 : 1, + duration: const Duration(milliseconds: 150), + child: Center( + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon(Symbols.play_arrow, + color: Colors.white, size: 40), + ), + ), + ), + ), + ), + if (ready) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: VideoProgressIndicator( + c, + allowScrubbing: true, + colors: const VideoProgressColors(playedColor: Colors.white), + ), + ), + Positioned( + top: MediaQuery.of(context).padding.top + 8, + left: 8, + child: IconButton( + icon: const Icon(Symbols.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ); + } + + Widget _buildLoading() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Colors.white, + value: _progress > 0 && _progress < 1 ? _progress : null, + ), + if (_progress > 0 && _progress < 1) ...[ + const SizedBox(height: 12), + Text( + '${(_progress * 100).round()}%', + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 963fb5c..8c9a99f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -168,5 +168,8 @@ "editProfileSave": "Save", "editProfileFirstName": "First name", "editProfileLastName": "Last name", - "editProfileRemovePhoto": "Remove photo" + "editProfileRemovePhoto": "Remove photo", + "registrationTitle": "Create your profile", + "registrationSubtitle": "Add your name and pick an avatar", + "registrationChooseAvatar": "Choose an avatar" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ec39c9d..eef4bee 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1009,6 +1009,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Remove photo'** String get editProfileRemovePhoto; + + /// No description provided for @registrationTitle. + /// + /// In en, this message translates to: + /// **'Create your profile'** + String get registrationTitle; + + /// No description provided for @registrationSubtitle. + /// + /// In en, this message translates to: + /// **'Add your name and pick an avatar'** + String get registrationSubtitle; + + /// No description provided for @registrationChooseAvatar. + /// + /// In en, this message translates to: + /// **'Choose an avatar'** + String get registrationChooseAvatar; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 62d18b3..8303e6b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -478,4 +478,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get editProfileRemovePhoto => 'Remove photo'; + + @override + String get registrationTitle => 'Create your profile'; + + @override + String get registrationSubtitle => 'Add your name and pick an avatar'; + + @override + String get registrationChooseAvatar => 'Choose an avatar'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7b439e7..20ab999 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -480,4 +480,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get editProfileRemovePhoto => 'Удалить фото'; + + @override + String get registrationTitle => 'Создание профиля'; + + @override + String get registrationSubtitle => 'Укажите имя и выберите аватар'; + + @override + String get registrationChooseAvatar => 'Выберите аватар'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7b3739e..ef69871 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -168,5 +168,8 @@ "editProfileSave": "Сохранить", "editProfileFirstName": "Имя", "editProfileLastName": "Фамилия", - "editProfileRemovePhoto": "Удалить фото" + "editProfileRemovePhoto": "Удалить фото", + "registrationTitle": "Создание профиля", + "registrationSubtitle": "Укажите имя и выберите аватар", + "registrationChooseAvatar": "Выберите аватар" } diff --git a/lib/main.dart b/lib/main.dart index 905dedb..89b0cdf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:m3e_collection/m3e_collection.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; +import 'core/cache/info_cache.dart'; import 'core/config/app_accent.dart'; import 'core/config/app_amoled.dart'; import 'core/config/app_bubble_behavior.dart'; @@ -18,6 +19,9 @@ import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; import 'core/config/app_message_actions_style.dart'; import 'core/config/app_swipe_back_desktop.dart'; +import 'core/config/app_pranks.dart'; +import 'core/config/app_stories.dart'; +import 'core/config/app_media_cache.dart'; import 'core/config/app_theme_mode.dart'; import 'core/config/app_theme_schedule.dart'; import 'backend/modules/account.dart'; @@ -25,6 +29,7 @@ import 'backend/modules/chats.dart'; import 'backend/modules/contacts.dart'; import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; +import 'backend/modules/polls.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; import 'core/transport/tls_config.dart'; @@ -41,7 +46,10 @@ import 'frontend/widgets/theme_reveal.dart'; final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); +final pollsModule = PollsModule(api); final fileUploader = FileUploader(api: api, messages: messagesModule); +final RouteObserver> appRouteObserver = + RouteObserver>(); Future _loadInitialLocale() async { final prefs = await SharedPreferences.getInstance(); @@ -63,19 +71,38 @@ void main() async { if (activeAccountId != null) { await ContactsModule.primeCacheFromDb(activeAccountId); } + attachInfoCacheApi(api); ChatsModule.attachGlobalPushHandlers(api); + + final packageInfoFuture = PackageInfo.fromPlatform(); + final localeFuture = _loadInitialLocale(); + final hapticsFuture = Haptics.load(); + final prefsFuture = SharedPreferences.getInstance(); + final accentFuture = AppAccent.load(); + final bubbleShapeFuture = AppBubbleShape.load(); + final bubbleBehaviorFuture = AppBubbleBehavior.load(); + final cacheExtentFuture = AppCacheExtent.load(); + final themeModeFuture = AppThemeModeConfig.load(); + final amoledFuture = AppAmoled.load(); + final themeScheduleFuture = AppThemeSchedule.load(); + final messageActionsFuture = AppMessageActionsStyle.load(); + final swipeBackFuture = AppSwipeBackDesktop.load(); + final pranksFuture = AppPranks.load(); + final storiesFuture = AppStories.load(); + final cacheLimitFuture = AppMediaCacheLimit.load(); + await api.connect(); - final packageInfo = await PackageInfo.fromPlatform(); + final packageInfo = await packageInfoFuture; if (packageInfo.packageName == 'ru.oneme.app') { await PushService.instance.init(api: api, account: accountModule); } - final initialLocale = await _loadInitialLocale(); + final initialLocale = await localeFuture; - await Haptics.load(); + await hapticsFuture; - final prefs = await SharedPreferences.getInstance(); + final prefs = await prefsFuture; await FileHistoryCache.load(prefs); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false; @@ -85,15 +112,18 @@ void main() async { final initialFontScale = AppFonts.clampScale( prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale, ); - final initialAccentSeed = await AppAccent.load(); - AppBubbleShape.current.value = await AppBubbleShape.load(); - AppBubbleBehavior.current.value = await AppBubbleBehavior.load(); - AppCacheExtent.current.value = await AppCacheExtent.load(); - AppThemeModeConfig.current.value = await AppThemeModeConfig.load(); - AppAmoled.current.value = await AppAmoled.load(); - AppThemeSchedule.current.value = await AppThemeSchedule.load(); - AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load(); - AppSwipeBackDesktop.current.value = await AppSwipeBackDesktop.load(); + final initialAccentSeed = await accentFuture; + AppBubbleShape.current.value = await bubbleShapeFuture; + AppBubbleBehavior.current.value = await bubbleBehaviorFuture; + AppCacheExtent.current.value = await cacheExtentFuture; + AppThemeModeConfig.current.value = await themeModeFuture; + AppAmoled.current.value = await amoledFuture; + AppThemeSchedule.current.value = await themeScheduleFuture; + AppMessageActionsStyle.current.value = await messageActionsFuture; + AppSwipeBackDesktop.current.value = await swipeBackFuture; + AppPranks.current.value = await pranksFuture; + AppStories.current.value = await storiesFuture; + AppMediaCacheLimit.current.value = await cacheLimitFuture; runApp( KometApp( initialLocale: initialLocale, @@ -626,6 +656,7 @@ class KometAppState extends State theme: _lightTheme, darkTheme: _darkTheme, navigatorKey: KometApp.navigatorKey, + navigatorObservers: [appRouteObserver], builder: (context, child) { return ValueListenableBuilder( valueListenable: fontScale, @@ -683,8 +714,13 @@ class _StartupScreenState extends State<_StartupScreen> { } Future _tryAutoLogin() async { - final accountId = await TokenStorage.getActiveAccountId(); + int? accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null || await TokenStorage.readToken(accountId) == null) { + accountId = await _recoverActiveAccount(); + } + + if (accountId == null) { _goToLogin(); return; } @@ -701,6 +737,19 @@ class _StartupScreenState extends State<_StartupScreen> { } } + Future _recoverActiveAccount() async { + final profiles = await AppDatabase.loadAllProfiles(); + for (final profile in profiles) { + if (await TokenStorage.readToken(profile.id) != null) { + await TokenStorage.setActiveAccount(profile.id); + await AppDatabase.setActiveAccount(profile.id); + await ContactsModule.primeCacheFromDb(profile.id); + return profile.id; + } + } + return null; + } + void _goToLogin() { if (mounted) { Navigator.pushReplacement( diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index dd76314..4ee7718 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -7,6 +7,7 @@ enum AttachmentType { location, sticker, control, + poll, } abstract class MessageAttachment { @@ -41,6 +42,8 @@ abstract class MessageAttachment { return LocationAttachment.fromMap(map); case 'CONTROL': return ControlAttachment.fromMap(map); + case 'POLL': + return PollAttachment.fromMap(map); case 'SHARE': return FileAttachment.fromMap(map); case 'INLINE_KEYBOARD': @@ -471,6 +474,31 @@ class ControlAttachment extends MessageAttachment { }; } +class PollAttachment extends MessageAttachment { + final int pollId; + final String? title; + + const PollAttachment({ + required this.pollId, + this.title, + }) : super(type: AttachmentType.poll); + + factory PollAttachment.fromMap(Map map) { + final id = map['pollId'] ?? map['id']; + return PollAttachment( + pollId: id is int ? id : int.tryParse(id?.toString() ?? '') ?? 0, + title: (map['title'] ?? map['question'])?.toString(), + ); + } + + @override + Map toMap() => { + '_type': 'POLL', + 'pollId': pollId, + 'title': title, + }; +} + class ForwardedMessageAttachment extends MessageAttachment { final int originalSenderId; final String? originalSenderName; diff --git a/lib/models/poll.dart b/lib/models/poll.dart new file mode 100644 index 0000000..0f9cc3d --- /dev/null +++ b/lib/models/poll.dart @@ -0,0 +1,87 @@ +class PollAnswer { + final int answerId; + final String text; + final int voteCount; + final double rate; + final List votes; + + const PollAnswer({ + required this.answerId, + required this.text, + this.voteCount = 0, + this.rate = 0, + this.votes = const [], + }); +} + +class Poll { + final int pollId; + final String title; + final int settings; + final int version; + final int total; + final List answers; + final List voterPreviewIds; + + const Poll({ + required this.pollId, + required this.title, + this.settings = 0, + this.version = 0, + this.total = 0, + this.answers = const [], + this.voterPreviewIds = const [], + }); + + bool get isMultiple => settings & 0x1 != 0; + + bool votedBy(int userId) => + answers.any((a) => a.votes.contains(userId)); + + factory Poll.fromServerMap(Map map) { + final state = map['state']; + final stateMap = state is Map ? state : const {}; + + final resultsById = {}; + final result = stateMap['result']; + if (result is List) { + for (final r in result) { + if (r is Map && r['answerId'] is int) { + resultsById[r['answerId'] as int] = r; + } + } + } + + final answers = []; + final rawAnswers = map['answers']; + if (rawAnswers is List) { + for (final a in rawAnswers) { + if (a is! Map) continue; + final id = a['answerId'] as int? ?? 0; + final res = resultsById[id]; + answers.add(PollAnswer( + answerId: id, + text: a['text']?.toString() ?? '', + voteCount: (res?['voteCount'] as num?)?.toInt() ?? 0, + rate: (res?['rate'] as num?)?.toDouble() ?? 0, + votes: (res?['votes'] as List?) + ?.whereType() + .toList() ?? + const [], + )); + } + } + + return Poll( + pollId: map['pollId'] as int? ?? 0, + title: map['title']?.toString() ?? '', + settings: map['settings'] as int? ?? 0, + version: map['version'] as int? ?? 0, + total: (stateMap['total'] as num?)?.toInt() ?? 0, + answers: answers, + voterPreviewIds: + (stateMap['voterPreviewIds'] as List?)?.whereType().toList() ?? + const [], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index f44d5e2..9d4e8db 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -146,13 +146,21 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" dart_lz4: dependency: "direct main" description: @@ -421,6 +429,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -446,7 +462,7 @@ packages: source: hosted version: "0.2.1" image: - dependency: transitive + dependency: "direct main" description: name: image sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce @@ -585,10 +601,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mobile_scanner: dependency: "direct main" description: @@ -645,6 +661,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" + url: "https://pub.dev" + source: hosted + version: "4.7.0" package_info_plus: dependency: "direct main" description: @@ -670,7 +694,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -958,10 +982,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" timezone: dependency: "direct main" description: @@ -1002,6 +1026,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" + url: "https://pub.dev" + source: hosted + version: "2.9.5" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58" + url: "https://pub.dev" + source: hosted + version: "2.9.7" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0" + url: "https://pub.dev" + source: hosted + version: "6.7.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ebb0e75..ecc0898 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,11 +39,13 @@ dependencies: dart_lz4: ^1.0.0 libcompress: ^1.0.0 msgpack_dart: ^1.0.1 + crypto: ^3.0.7 logger: ^2.6.2 device_info_plus: 12.3.0 flutter_timezone: ^5.0.1 timezone: ^0.11.0 file_picker: ^8.0.0 + image: ^4.3.0 sqflite: ^2.4.2 sqflite_common_ffi: ^2.4.0+2 path: ^1.9.1 @@ -55,6 +57,9 @@ dependencies: package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 cached_network_image: ^3.4.1 + path_provider: ^2.1.4 + open_filex: ^4.5.0 + video_player: ^2.9.2 firebase_core: ^4.1.1 firebase_messaging: ^16.0.2 flutter_local_notifications: ^21.0.0