From 91069f53ea3ffdf3306a0bd10bb81ced29006afe Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 3 Apr 2026 19:35:45 +0700 Subject: [PATCH 01/59] =?UTF-8?q?=D1=8F=20=D0=BA=D0=B0=D0=BA=20=D0=B1?= =?UTF-8?q?=D1=8B=20=D0=B5=D1=89=D0=B5=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B0=D0=BB,=20=D1=8D=D1=82=D0=BE=20=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=BC=D0=B5=D0=B6=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=83=D1=88=20?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D0=B5=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 + lib/backend/api.dart | 110 +++---- lib/backend/modules/account.dart | 50 ++-- lib/backend/modules/chats.dart | 138 ++++++--- lib/backend/modules/contacts.dart | 56 ++++ lib/core/storage/app_database.dart | 112 +++++-- lib/core/storage/token_storage.dart | 37 ++- lib/core/transport/dispatcher.dart | 9 +- lib/core/transport/sender.dart | 5 +- .../auth/code_confirmation_screen.dart | 279 ++++++++++++------ lib/frontend/screens/auth/login_screen.dart | 65 +++- .../screens/auth/password_2fa_screen.dart | 18 +- .../screens/chats/chat_list_screen.dart | 162 ++++++---- lib/frontend/screens/chats/chat_screen.dart | 34 ++- .../screens/profile/settings_tab.dart | 235 ++++++++++++--- lib/main.dart | 73 ++++- pubspec.lock | 16 +- 17 files changed, 987 insertions(+), 417 deletions(-) create mode 100644 AGENTS.md create mode 100644 lib/backend/modules/contacts.dart diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c894244 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +Не оставляй комментарии в код +Старайся писать чистый код +Лучше качество чем количество +Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно. +ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст') diff --git a/lib/backend/api.dart b/lib/backend/api.dart index f014ad8..59efde3 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -16,12 +16,7 @@ import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/timezone.dart' as tz; import 'package:flutter_timezone/flutter_timezone.dart'; -enum SessionState { - disconnected, - connecting, - connected, - online -} +enum SessionState { disconnected, connecting, connected, online } /// Клиент API. /// @@ -34,6 +29,9 @@ class Api { SessionState _sessionState = SessionState.disconnected; final _stateController = StreamController.broadcast(); + Map? _userAgent; + + Map? get userAgent => _userAgent; Stream get stateStream => _stateController.stream; SessionState get state => _sessionState; @@ -98,86 +96,77 @@ class Api { _setSessionState(SessionState.disconnected); } - /// Отправляет хэндшейк (opcode 6). Future sendHandshake() async { - DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + final deviceInfo = DeviceInfoPlugin(); - // Если платформа Linux или Windows, то ставим DESKTOP, если нет, то проверяем на Android или IOS; - String deviceType = (Platform.isLinux || Platform.isWindows) ? "DESKTOP" : (Platform.isAndroid) ? "ANDROID" : "IOS"; - String osVersion = ""; - String deviceName = "Unknown"; - String architecture = "arm64"; - - tz.initializeTimeZones(); - - final now = DateTime.now(); - String timezone = "Europe/Moscow"; + final deviceType = (Platform.isLinux || Platform.isWindows) + ? 'DESKTOP' + : (Platform.isAndroid) + ? 'ANDROID' + : 'IOS'; + String osVersion = ''; + String deviceName = 'Unknown'; + String architecture = 'arm64'; tz.initializeTimeZones(); final timeZoneName = await FlutterTimezone.getLocalTimezone(); - timezone = timeZoneName.identifier; + final timezone = timeZoneName.identifier; - // На каждой платформе свое инфо, поэтому делаем такую проверку if (Platform.isLinux) { - LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo; - + final linuxInfo = await deviceInfo.linuxInfo; osVersion = linuxInfo.name; - // Platform.version содержит в себе что-то такое - // 3.11.1 (stable) (Tue Feb 24 00:03:07 2026 -0800) on "linux_x64" - // Поэтому мы находим '_', прибавляем к его индексу 1 и берем символы до length - 1 - architecture = Platform.version.substring(Platform.version.indexOf('_') + 1, Platform.version.length - 1); + architecture = Platform.version.substring( + Platform.version.indexOf('_') + 1, + Platform.version.length - 1, + ); } else if (Platform.isIOS) { - IosDeviceInfo iosInfo = await deviceInfo.iosInfo; - + final iosInfo = await deviceInfo.iosInfo; osVersion = iosInfo.systemVersion; deviceName = iosInfo.utsname.machine; } else if (Platform.isAndroid) { - AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; - - osVersion = "Android ${androidInfo.version.release}"; - deviceName = "${androidInfo.manufacturer} ${androidInfo.model}"; - architecture = androidInfo.supportedAbis.first; + final androidInfo = await deviceInfo.androidInfo; + osVersion = 'Android ${androidInfo.version.release}'; + deviceName = '${androidInfo.manufacturer} ${androidInfo.model}'; + architecture = androidInfo.supportedAbis.first; } else if (Platform.isWindows) { - WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo; - + final windowsInfo = await deviceInfo.windowsInfo; osVersion = windowsInfo.productName; - architecture = Platform.version.substring(Platform.version.indexOf('_') + 1, Platform.version.length - 1); + architecture = Platform.version.substring( + Platform.version.indexOf('_') + 1, + Platform.version.length - 1, + ); } - - print(deviceType); + + _userAgent = { + 'deviceType': deviceType, + 'locale': 'ru', + 'deviceLocale': Platform.localeName.substring(0, 2), + 'osVersion': osVersion, + 'deviceName': deviceName, + 'appVersion': '26.8.1', + 'screen': '1920x1080', + 'timezone': timezone, + 'pushDeviceType': 'GCM', + 'arch': architecture, + 'buildNumber': 6606, + }; + final payload = { 'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000', 'clientSessionId': 42, 'deviceId': 'a1b2c3d4e5f6a7b8', - 'userAgent': { - 'deviceType': deviceType, - // Первые два символа из locale это и есть нужный нам аргумент - 'locale': "ru", - 'deviceLocale': Platform.localeName.substring(0, 2), - 'osVersion': osVersion, - 'deviceName': deviceName, - 'appVersion': '26.8.1', - 'screen': '1920x1080', - // 'screen': screenSize.width + 'x' + screenSize.height, - 'timezone': timezone, - 'pushDeviceType': 'GCM', - 'arch': architecture, - 'buildNumber': 6606, - }, + 'userAgent': _userAgent, }; - print(payload); - print(Platform.version); return sendRequest(Opcode.sessionInit, payload); } /// Отправляет запрос и ждёт ответ от сервера. - Future sendRequest( - int opcode, - Map payload, - ) { + Future sendRequest(int opcode, Map payload) { final seq = _sender.send(_connection, opcode, payload); - return _dispatcher.registerPending(seq).timeout( + return _dispatcher + .registerPending(seq) + .timeout( ServerConfig.requestTimeout, onTimeout: () => throw TimeoutException('${Opcode.name(opcode)} таймаут'), @@ -203,7 +192,6 @@ class Api { // Внутрянка - void _setSessionState(SessionState state) { if (_sessionState == state) return; _sessionState = state; diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 57ce448..b8ff3c3 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -5,6 +5,14 @@ import '../../core/storage/app_database.dart'; import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import 'chats.dart'; +import 'contacts.dart'; + +class ServerException implements Exception { + final String message; + const ServerException(this.message); + @override + String toString() => message; +} enum AuthRequestType { startAuth('START_AUTH'), @@ -115,11 +123,13 @@ class LoginResult { final ProfileData profile; final String? updatedToken; final int serverTime; + final Map raw; const LoginResult({ required this.profile, required this.updatedToken, required this.serverTime, + required this.raw, }); } @@ -131,14 +141,12 @@ class AccountModule { Future requestCode( String phone, { String language = 'ru', - }) => - _requestCodeInternal(phone, AuthRequestType.startAuth, language); + }) => _requestCodeInternal(phone, AuthRequestType.startAuth, language); Future resendCode( String phone, { String language = 'ru', - }) => - _requestCodeInternal(phone, AuthRequestType.resend, language); + }) => _requestCodeInternal(phone, AuthRequestType.resend, language); Future verifyCode(String code, String token) async { _ensureOnline(); @@ -157,7 +165,9 @@ class AccountModule { final data = packet.payload; if (data is! Map) { - throw Exception('verifyCode: неожиданный тип payload: ${data.runtimeType}'); + throw Exception( + 'verifyCode: неожиданный тип payload: ${data.runtimeType}', + ); } final result = VerifyCodeResult(payload: data.cast()); @@ -181,7 +191,8 @@ class AccountModule { }) async { _ensureOnline(); - final resolvedAccountId = accountId ?? await TokenStorage.getActiveAccountId(); + final resolvedAccountId = + accountId ?? await TokenStorage.getActiveAccountId(); if (resolvedAccountId == null) { throw StateError('login: нет активного аккаунта'); } @@ -191,13 +202,7 @@ class AccountModule { throw StateError('login: нет токена для аккаунта $resolvedAccountId'); } - final resolvedSyncParams = - syncParams ?? await LoginSyncParams.fromDatabase(resolvedAccountId); - - final requestPayload = _buildLoginPayload(authToken, resolvedSyncParams); - - logger.i('LOGIN opcode=${Opcode.login} ' - 'account=$resolvedAccountId warm=${resolvedSyncParams != null}'); + final requestPayload = _buildLoginPayload(authToken, syncParams); final packet = await _api.sendRequest(Opcode.login, requestPayload); @@ -262,7 +267,9 @@ class AccountModule { final data = packet.payload; if (data is! Map) { - throw Exception('checkPassword: неожиданный тип payload: ${data.runtimeType}'); + throw Exception( + 'checkPassword: неожиданный тип payload: ${data.runtimeType}', + ); } if (data['error'] != null) { @@ -310,7 +317,8 @@ class AccountModule { ) { final payload = { 'token': token, - 'interactive': true + 'interactive': true, + if (_api.userAgent != null) 'userAgent': _api.userAgent, }; if (sync != null) { @@ -342,7 +350,6 @@ class AccountModule { final updatedToken = data['token'] as String?; if (updatedToken != null) { await TokenStorage.saveToken(updatedToken, accountId); - logger.i('Обновлённый токен аккаунта $accountId сохранён'); } final profileMap = data['profile']; @@ -356,15 +363,16 @@ class AccountModule { final profile = ProfileData.fromServerMap(contact.cast()); await AppDatabase.saveProfile(profile); await AppDatabase.setActiveAccount(profile.id); - logger.i('Профиль сохранён: id=${profile.id}, name=${profile.firstName}'); await _saveSyncState(data, serverTime, profile.id); + await ContactsModule.syncFromLoginPayload(data, profile.id); await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id); return LoginResult( profile: profile, updatedToken: updatedToken, serverTime: serverTime, + raw: data, ); } @@ -415,7 +423,9 @@ class AccountModule { final data = packet.payload; if (data is! Map) { - throw Exception('requestCode: неожиданный тип payload: ${data.runtimeType}'); + throw Exception( + 'requestCode: неожиданный тип payload: ${data.runtimeType}', + ); } final token = data['token']; @@ -439,8 +449,8 @@ class AccountModule { if (packet.isError) { final errMsg = packet.payload is Map ? (packet.payload as Map)['message'] ?? packet.payload.toString() - : packet.payload?.toString() ?? 'unknown error'; - throw Exception('$method: ошибка от сервера — $errMsg'); + : packet.payload?.toString() ?? 'Неизвестная ошибка'; + throw ServerException(errMsg.toString()); } } } diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index d9c7d91..33413d3 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -13,6 +13,10 @@ class CachedChat { final int unreadCount; final int lastEventTime; final int cachedAt; + final int? favIndex; + final int dontDisturbUntil; + final bool isOnline; + final int seenTime; const CachedChat({ required this.id, @@ -27,37 +31,49 @@ class CachedChat { required this.unreadCount, required this.lastEventTime, required this.cachedAt, + this.favIndex, + required this.dontDisturbUntil, + required this.isOnline, + required this.seenTime, }); factory CachedChat.fromDbRow(Map row) => CachedChat( - id: row['id'] as int, - accountId: row['account_id'] as int, - type: row['type'] as String, - title: row['title'] as String?, - iconUrl: row['icon_url'] as String?, - lastMsgId: row['last_msg_id'] as int?, - lastMsgTime: row['last_msg_time'] as int?, - lastMsgText: row['last_msg_text'] as String?, - lastMsgSenderId: row['last_msg_sender'] as int?, - unreadCount: row['unread_count'] as int, - lastEventTime: row['last_event_time'] as int, - cachedAt: row['cached_at'] as int, - ); + id: row['id'] as int, + accountId: row['account_id'] as int, + type: row['type'] as String, + title: row['title'] as String?, + iconUrl: row['icon_url'] as String?, + lastMsgId: row['last_msg_id'] as int?, + lastMsgTime: row['last_msg_time'] as int?, + lastMsgText: row['last_msg_text'] as String?, + lastMsgSenderId: row['last_msg_sender'] as int?, + unreadCount: row['unread_count'] as int, + lastEventTime: row['last_event_time'] as int, + cachedAt: row['cached_at'] as int, + favIndex: row['fav_index'] as int?, + dontDisturbUntil: row['dont_disturb_until'] as int, + isOnline: (row['is_online'] as int) == 1, + seenTime: row['seen_time'] as int, + ); Map toDbRow() => { - 'id': id, - 'account_id': accountId, - 'type': type, - 'title': title, - 'icon_url': iconUrl, - 'last_msg_id': lastMsgId, - 'last_msg_time': lastMsgTime, - 'last_msg_text': lastMsgText, - 'last_msg_sender': lastMsgSenderId, - 'unread_count': unreadCount, - 'last_event_time': lastEventTime, - 'cached_at': cachedAt, - }; + 'id': id, + 'account_id': accountId, + 'type': type, + 'title': title, + 'icon_url': iconUrl, + 'last_msg_id': lastMsgId, + 'last_msg_time': lastMsgTime, + 'last_msg_text': lastMsgText, + 'last_msg_sender': lastMsgSenderId, + 'unread_count': unreadCount, + 'last_event_time': lastEventTime, + 'cached_at': cachedAt, + 'fav_index': favIndex, + 'dont_disturb_until': dontDisturbUntil, + 'is_online': isOnline ? 1 : 0, + 'seen_time': seenTime, + }; } class ChatsModule { @@ -75,23 +91,35 @@ class ChatsModule { if (chats is! List || chats.isEmpty) return; final contactsMap = _buildContactsMap(data['contacts']); + // Config contains mute setup and fav indexes: config -> chats -> id + final configMap = data['config'] is Map ? data['config'] as Map : {}; + final chatsConfig = configMap['chats'] is Map + ? configMap['chats'] as Map + : {}; + // Presence for online statuses + final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; final cachedAt = DateTime.now().millisecondsSinceEpoch; final existingRows = await AppDatabase.loadChats(accountId); final existing = { - for (final row in existingRows) row['id'] as int: CachedChat.fromDbRow(row), + for (final row in existingRows) + row['id'] as int: CachedChat.fromDbRow(row), }; final rows = chats .whereType() - .map((c) => _parseChat( - c.cast(), - accountId, - currentUserId, - contactsMap, - existing, - cachedAt, - )) + .map( + (c) => _parseChat( + c.cast(), + accountId, + currentUserId, + contactsMap, + chatsConfig, + presenceMap, + existing, + cachedAt, + ), + ) .whereType() .map((c) => c.toDbRow()) .toList(); @@ -109,7 +137,7 @@ class ChatsModule { static Future clearCache(int accountId) => AppDatabase.clearChatsCache(accountId); - // internal + // internal static Map> _buildContactsMap(dynamic contacts) { if (contacts is! List) return {}; @@ -126,6 +154,8 @@ class ChatsModule { int accountId, int currentUserId, Map> contactsMap, + Map chatsConfig, + Map presenceMap, Map existing, int cachedAt, ) { @@ -133,19 +163,19 @@ class ChatsModule { if (id is! int) return null; final type = (chat['type'] as String?) ?? 'DIALOG'; + int? otherId; String? title; String? iconUrl; if (type == 'DIALOG') { - final otherId = _otherParticipantId(chat['participants'], currentUserId); + otherId = _otherParticipantId(chat['participants'], currentUserId); final contact = otherId != null ? contactsMap[otherId] : null; if (contact != null) { title = _nameFromContact(contact); iconUrl = contact['baseUrl'] as String?; } else { - // Warm start: контакты не пришли — берём из кэша title = existing[id]?.title; iconUrl = existing[id]?.iconUrl; } @@ -167,6 +197,24 @@ class ChatsModule { lastMsgSenderId = lastMsg['sender'] as int?; } + final config = chatsConfig[id.toString()] ?? chatsConfig[id]; + int? favIndex; + int dontDisturbUntil = 0; + if (config is Map) { + favIndex = config['favIndex'] as int?; + dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; + } + + int seenTime = 0; + bool isOnline = false; + if (type == 'DIALOG' && otherId != null) { + final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; + if (presence is Map) { + seenTime = (presence['seen'] as int?) ?? 0; + isOnline = (presence['status'] as int?) == 1; + } + } + return CachedChat( id: id, accountId: accountId, @@ -180,6 +228,10 @@ class ChatsModule { unreadCount: (chat['newMessages'] as int?) ?? 0, lastEventTime: (chat['lastEventTime'] as int?) ?? 0, cachedAt: cachedAt, + favIndex: favIndex, + dontDisturbUntil: dontDisturbUntil, + isOnline: isOnline, + seenTime: seenTime, ); } @@ -195,10 +247,12 @@ class ChatsModule { static String? _nameFromContact(Map contact) { final names = contact['names']; if (names is! List || names.isEmpty) return null; - final name = names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.first, - ) as Map; + final name = + names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) + as Map; return name['name'] as String?; } } diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart new file mode 100644 index 0000000..7d6614a --- /dev/null +++ b/lib/backend/modules/contacts.dart @@ -0,0 +1,56 @@ +import '../../core/storage/app_database.dart'; + +class ContactsModule { + static Future syncFromLoginPayload( + Map data, + int accountId, + ) async { + final contacts = data['contacts']; + if (contacts is! List || contacts.isEmpty) return; + + final rows = contacts + .whereType() + .map((c) => _parseContact(c.cast(), accountId)) + .whereType>() + .toList(); + + if (rows.isNotEmpty) { + await AppDatabase.saveContacts(rows); + } + } + + static Map? _parseContact( + Map contact, + int accountId, + ) { + final id = contact['id']; + if (id is! int) return null; + + String firstName = ''; + String? lastName; + + final names = contact['names']; + if (names is List && names.isNotEmpty) { + final name = + names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) + as Map; + firstName = (name['firstName'] as String?) ?? ''; + lastName = name['lastName'] as String?; + } + + return { + 'id': id, + 'account_id': accountId, + 'first_name': firstName, + 'last_name': lastName, + 'phone': (contact['phone'] as int?) ?? 0, + 'photo_id': contact['photoId'] as int?, + 'base_url': contact['baseUrl'] as String?, + 'base_raw_url': contact['baseRawUrl'] as String?, + 'update_time': (contact['updateTime'] as int?) ?? 0, + }; + } +} diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 3c0409a..7f5af5f 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -35,10 +35,12 @@ class ProfileData { String? lastName; if (names is List && names.isNotEmpty) { - final name = names.firstWhere( - (n) => n is Map && n['type'] == 'ONEME', - orElse: () => names.first, - ) as Map; + final name = + names.firstWhere( + (n) => n is Map && n['type'] == 'ONEME', + orElse: () => names.first, + ) + as Map; firstName = (name['firstName'] as String?) ?? ''; lastName = name['lastName'] as String?; } @@ -73,17 +75,17 @@ class ProfileData { } Map toDbRow() => { - 'id': id, - 'first_name': firstName, - 'last_name': lastName, - 'phone': phone, - 'photo_id': photoId, - 'base_url': baseUrl, - 'base_raw_url': baseRawUrl, - 'country': country, - 'account_status': accountStatus, - 'update_time': updateTime, - }; + 'id': id, + 'first_name': firstName, + 'last_name': lastName, + 'phone': phone, + 'photo_id': photoId, + 'base_url': baseUrl, + 'base_raw_url': baseRawUrl, + 'country': country, + 'account_status': accountStatus, + 'update_time': updateTime, + }; } abstract class SyncKey { @@ -118,7 +120,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 3, + version: 5, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -132,6 +134,13 @@ class AppDatabase { if (oldVersion < 3) { await db.execute(_chatsCacheSchema); } + if (oldVersion < 4) { + await db.execute(_contactsSchema); + } + if (oldVersion < 5) { + await db.execute('DROP TABLE IF EXISTS chats_cache'); + await db.execute(_chatsCacheSchema); + } }, ); } @@ -154,8 +163,23 @@ class AppDatabase { '''); await db.execute(_syncStateSchema); await db.execute(_chatsCacheSchema); + await db.execute(_contactsSchema); } + static const _contactsSchema = ''' + CREATE TABLE contacts ( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + first_name TEXT NOT NULL, + last_name TEXT, + phone INTEGER NOT NULL, + photo_id INTEGER, + base_url TEXT, + base_raw_url TEXT, + update_time INTEGER NOT NULL DEFAULT 0 + ) + '''; + static const _syncStateSchema = ''' CREATE TABLE sync_state ( account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, @@ -179,6 +203,10 @@ class AppDatabase { unread_count INTEGER NOT NULL DEFAULT 0, last_event_time INTEGER NOT NULL DEFAULT 0, cached_at INTEGER NOT NULL, + fav_index INTEGER, + dont_disturb_until INTEGER NOT NULL DEFAULT 0, + is_online INTEGER NOT NULL DEFAULT 0, + seen_time INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (id, account_id) ) '''; @@ -212,11 +240,7 @@ class AppDatabase { static Future loadActiveProfile() async { final db = await _instance; - final rows = await db.query( - 'profile', - where: 'is_active = 1', - limit: 1, - ); + final rows = await db.query('profile', where: 'is_active = 1', limit: 1); if (rows.isEmpty) return null; return ProfileData.fromDbRow(rows.first); } @@ -245,11 +269,11 @@ class AppDatabase { String value, ) async { final db = await _instance; - await db.insert( - 'sync_state', - {'account_id': accountId, 'key': key, 'value': value}, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.insert('sync_state', { + 'account_id': accountId, + 'key': key, + 'value': value, + }, conflictAlgorithm: ConflictAlgorithm.replace); } static Future getSyncValue(int accountId, String key) async { @@ -281,13 +305,17 @@ class AppDatabase { _db = null; } - // Chats cache + // Chats cache static Future saveChats(List> rows) async { final db = await _instance; final batch = db.batch(); for (final row in rows) { - batch.insert('chats_cache', row, conflictAlgorithm: ConflictAlgorithm.replace); + batch.insert( + 'chats_cache', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); } await batch.commit(noResult: true); } @@ -304,6 +332,32 @@ class AppDatabase { static Future clearChatsCache(int accountId) async { final db = await _instance; - await db.delete('chats_cache', where: 'account_id = ?', whereArgs: [accountId]); + await db.delete( + 'chats_cache', + where: 'account_id = ?', + whereArgs: [accountId], + ); + } + + static Future saveContacts(List> rows) async { + final db = await _instance; + final batch = db.batch(); + for (final row in rows) { + batch.insert( + 'contacts', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + + static Future>> loadContacts(int accountId) async { + final db = await _instance; + return db.query( + 'contacts', + where: 'account_id = ?', + whereArgs: [accountId], + ); } } diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart index 657ae14..75ab4bb 100644 --- a/lib/core/storage/token_storage.dart +++ b/lib/core/storage/token_storage.dart @@ -1,27 +1,32 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class TokenStorage { static const _tokenPrefix = 'auth_token_'; static const _activeAccountKey = 'active_account_id'; - static const _storage = FlutterSecureStorage( - aOptions: AndroidOptions(encryptedSharedPreferences: true), - ); + static Future saveToken(String token, int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('$_tokenPrefix$accountId', token); + } - static Future saveToken(String token, int accountId) => - _storage.write(key: '$_tokenPrefix$accountId', value: token); + static Future readToken(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('$_tokenPrefix$accountId'); + } - static Future readToken(int accountId) => - _storage.read(key: '$_tokenPrefix$accountId'); + static Future deleteToken(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('$_tokenPrefix$accountId'); + } - static Future deleteToken(int accountId) => - _storage.delete(key: '$_tokenPrefix$accountId'); - - static Future setActiveAccount(int accountId) => - _storage.write(key: _activeAccountKey, value: accountId.toString()); + static Future setActiveAccount(int accountId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_activeAccountKey, accountId.toString()); + } static Future getActiveAccountId() async { - final val = await _storage.read(key: _activeAccountKey); + final prefs = await SharedPreferences.getInstance(); + final val = prefs.getString(_activeAccountKey); return val != null ? int.tryParse(val) : null; } @@ -31,12 +36,12 @@ class TokenStorage { return readToken(id); } - /// Удаляет токен аккаунта и, если он был активным, сбрасывает активный аккаунт. static Future deleteAccount(int accountId) async { await deleteToken(accountId); final activeId = await getActiveAccountId(); if (activeId == accountId) { - await _storage.delete(key: _activeAccountKey); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_activeAccountKey); } } } diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index efeec15..103f033 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -53,8 +53,9 @@ class PacketDispatcher { if (packet.cmd == CmdType.ok || packet.cmd == CmdType.error || packet.cmd == CmdType.notFound) { - final status = packet.isOk ? 'OK' : packet.isError ? 'ERR' : 'NOT_FOUND'; - logger.i('<= [$tag] seq=${packet.seq} $status\n payload: ${packet.payload}'); + logger.i( + '<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}', + ); final completer = _pendingRequests.remove(packet.seq); _requestTimestamps.remove(packet.seq); @@ -72,7 +73,9 @@ class PacketDispatcher { completer.complete(packet); } } else if (packet.isPush) { - logger.i('<= push [$tag] ${packet.payload}'); + logger.i( + '<= push {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}', + ); _pushHandlers[packet.opcode]?.call(packet); _pushController.add(packet); } diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index 31a5c2c..8ef642f 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -1,5 +1,4 @@ import '../protocol/packet.dart'; -import '../protocol/opcode_map.dart'; import '../utils/logger.dart'; import 'connection.dart'; @@ -19,7 +18,9 @@ class PacketSender { final seq = _nextSeq(); final data = packPacket(opcode, payload, seq: seq); connection.write(data); - logger.i('=> [${Opcode.name(opcode)}] seq=$seq\n payload: $payload'); + logger.i( + '=> {ver: 10, cmd: 0, seq: $seq, opcode: $opcode, payload: $payload}', + ); return seq; } } diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index ec9eefd..f86f656 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -5,13 +5,14 @@ import 'package:google_fonts/google_fonts.dart'; import '../chats/chat_list_screen.dart'; import 'password_2fa_screen.dart'; import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; class CodeConfirmationScreen extends StatefulWidget { final String phoneNumber; final String token; const CodeConfirmationScreen({ - super.key, + super.key, required this.phoneNumber, required this.token, }); @@ -20,11 +21,17 @@ class CodeConfirmationScreen extends StatefulWidget { State createState() => _CodeConfirmationScreenState(); } -class _CodeConfirmationScreenState extends State { +class _CodeConfirmationScreenState extends State + with TickerProviderStateMixin { final TextEditingController _codeController = TextEditingController(); final FocusNode _focusNode = FocusNode(); int _timerSeconds = 30; Timer? _timer; + Timer? _errorTimer; + + String? _errorMessage; + late AnimationController _shakeController; + late Animation _shakeAnimation; @override void initState() { @@ -33,11 +40,26 @@ class _CodeConfirmationScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); + + _shakeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _shakeAnimation = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: -8.0), weight: 1), + TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 2), + TweenSequenceItem(tween: Tween(begin: 8.0, end: -8.0), weight: 2), + TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 2), + TweenSequenceItem(tween: Tween(begin: 8.0, end: -4.0), weight: 2), + TweenSequenceItem(tween: Tween(begin: -4.0, end: 0.0), weight: 1), + ]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear)); } @override void dispose() { _timer?.cancel(); + _errorTimer?.cancel(); + _shakeController.dispose(); _codeController.dispose(); _focusNode.dispose(); super.dispose(); @@ -57,11 +79,18 @@ class _CodeConfirmationScreenState extends State { }); } + void _showError(String message) { + _errorTimer?.cancel(); + _shakeController.forward(from: 0); + setState(() => _errorMessage = message); + _errorTimer = Timer(const Duration(seconds: 3), () { + if (mounted) setState(() => _errorMessage = null); + }); + } + void _resendCode() { if (_timerSeconds == 0) { _startTimer(); - // TODO: вызвать accountModule.resendCode - print('Resending code to ${widget.phoneNumber}'); } } @@ -78,29 +107,24 @@ class _CodeConfirmationScreenState extends State { if (result.requiresPassword) { final trackId = result.challengeTrackId; - + if (trackId == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Ошибка: отсутствуют данные для 2FA')), - ); + showCustomNotification(context, 'Ошибка: отсутствуют данные для 2FA'); return; } - + Navigator.pushReplacement( context, MaterialPageRoute( - builder: (context) => Password2FAScreen( - trackId: trackId, - hint: result.challengeHint, - ), + builder: (context) => + Password2FAScreen(trackId: trackId, hint: result.challengeHint), ), ); return; } - // Если 2FA не требуется, делаем login - final loginResult = await accountModule.login(); - + await accountModule.login(); + if (!mounted) return; Navigator.pushAndRemoveUntil( @@ -110,19 +134,15 @@ class _CodeConfirmationScreenState extends State { ); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Ошибка: $e')), - ); + _showError(e.toString()); } } - void _navigateToChats() { - _verifyCode(); - } - @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final hasError = _errorMessage != null; + return Scaffold( backgroundColor: cs.surface, appBar: AppBar( @@ -159,92 +179,155 @@ class _CodeConfirmationScreenState extends State { ), ), const SizedBox(height: 12), - Stack( - children: [ - Opacity( - opacity: 0, - child: SizedBox( - height: 0, - width: 0, - child: TextField( - controller: _codeController, - focusNode: _focusNode, - keyboardType: TextInputType.number, - autofillHints: const [AutofillHints.oneTimeCode], - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(6), - ], - onChanged: (value) { - setState(() {}); - if (value.length == 6) { - _navigateToChats(); - } - }, + AnimatedBuilder( + animation: _shakeAnimation, + builder: (context, child) => Transform.translate( + offset: Offset(_shakeAnimation.value, 0), + child: child, + ), + child: Stack( + children: [ + Opacity( + opacity: 0, + child: SizedBox( + height: 0, + width: 0, + child: TextField( + controller: _codeController, + focusNode: _focusNode, + keyboardType: TextInputType.number, + autofillHints: const [AutofillHints.oneTimeCode], + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + onChanged: (value) { + if (hasError) setState(() => _errorMessage = null); + setState(() {}); + if (value.length == 6) _verifyCode(); + }, + ), ), ), - ), - GestureDetector( - onTap: () => _focusNode.requestFocus(), - child: FittedBox( - child: Row( - children: List.generate(6, (index) { - bool isFocused = _codeController.text.length == index && _focusNode.hasFocus; - bool hasValue = _codeController.text.length > index; - String char = hasValue ? _codeController.text[index] : ''; + GestureDetector( + onTap: () => _focusNode.requestFocus(), + child: FittedBox( + child: Row( + children: List.generate(6, (index) { + final isFocused = + _codeController.text.length == index && + _focusNode.hasFocus; + final hasValue = + _codeController.text.length > index; + final char = hasValue + ? _codeController.text[index] + : ''; - return Container( - width: 44, - height: 54, - margin: EdgeInsets.only(right: index == 5 ? 0 : 10), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isFocused - ? cs.primary - : (hasValue ? cs.outlineVariant : Colors.transparent), - width: 1.5, + Color borderColor; + if (hasError && hasValue) { + borderColor = cs.error; + } else if (isFocused) { + borderColor = cs.primary; + } else if (hasValue) { + borderColor = cs.outlineVariant; + } else { + borderColor = Colors.transparent; + } + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 44, + height: 54, + margin: EdgeInsets.only( + right: index == 5 ? 0 : 10, ), - ), - alignment: Alignment.center, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 100), - transitionBuilder: (Widget child, Animation animation) { - return ScaleTransition( - scale: animation, - child: FadeTransition(opacity: animation, child: child), - ); - }, - child: Text( - char, - key: ValueKey(char + index.toString()), - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, + decoration: BoxDecoration( + color: hasError && hasValue + ? cs.error.withValues(alpha: 0.1) + : cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: borderColor, + width: 1.5, ), ), - ), - ); - }), + alignment: Alignment.center, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + transitionBuilder: + ( + Widget child, + Animation animation, + ) { + return ScaleTransition( + scale: animation, + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }, + child: Text( + char, + key: ValueKey( + char + + index.toString() + + (hasError ? 'e' : ''), + ), + style: TextStyle( + color: hasError && hasValue + ? cs.error + : cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + }), + ), ), ), - ), - ], + ], + ), + ), + const SizedBox(height: 16), + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + alignment: Alignment.topLeft, + child: hasError + ? Padding( + padding: const EdgeInsets.only(bottom: 8), + child: AnimatedOpacity( + opacity: hasError ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: Text( + _errorMessage!, + style: TextStyle( + color: cs.error, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ), + ) + : const SizedBox.shrink(), ), - const SizedBox(height: 24), GestureDetector( onTap: _resendCode, - child: Text( - _timerSeconds > 0 - ? 'Отправить повторно через $_timerSeconds сек.' - : 'Отправить код по SMS', + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), style: TextStyle( - color: cs.tertiary, + color: _timerSeconds > 0 ? cs.outline : cs.tertiary, fontSize: 14, fontWeight: FontWeight.w400, ), + child: Text( + _timerSeconds > 0 + ? 'Отправить повторно через $_timerSeconds сек.' + : 'Отправить код по SMS', + ), ), ), const Spacer(), @@ -253,9 +336,7 @@ class _CodeConfirmationScreenState extends State { children: [ FloatingActionButton( onPressed: () { - if (_codeController.text.length == 6) { - _navigateToChats(); - } + if (_codeController.text.length == 6) _verifyCode(); }, backgroundColor: _codeController.text.length == 6 ? cs.primaryContainer @@ -266,7 +347,9 @@ class _CodeConfirmationScreenState extends State { ), child: Icon( Icons.arrow_forward, - color: _codeController.text.length == 6 ? cs.onPrimaryContainer : cs.onSurfaceVariant, + color: _codeController.text.length == 6 + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, ), ), ], diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 89b463b..a72e298 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -23,6 +24,8 @@ class _LoginScreenState extends State { late CountryName _selectedCountry; bool _isPhoneValid = false; bool _isTOSRead = false; + String? _phoneError; + Timer? _phoneErrorTimer; @override void initState() { @@ -31,6 +34,13 @@ class _LoginScreenState extends State { _checkTOS(); } + @override + void dispose() { + _phoneErrorTimer?.cancel(); + _phoneController.dispose(); + super.dispose(); + } + Future _checkTOS() async { final prefs = await SharedPreferences.getInstance(); if (mounted) { @@ -308,9 +318,19 @@ class _LoginScreenState extends State { ); } + void _showPhoneError(String message) { + _phoneErrorTimer?.cancel(); + setState(() => _phoneError = message); + _phoneErrorTimer = Timer(const Duration(seconds: 4), () { + if (mounted) setState(() => _phoneError = null); + }); + } + void _showPhoneConfirmationDialog(String formattedPhone) { + final screenContext = context; + showGeneralDialog( - context: context, + context: screenContext, barrierDismissible: true, barrierLabel: '', barrierColor: Colors.black54, @@ -374,18 +394,22 @@ class _LoginScreenState extends State { TextButton( onPressed: () async { Navigator.pop(context); - - final fullPhone = '${_selectedCountry.phoneCode}${_phoneController.text}'; - + + final fullPhone = + '${_selectedCountry.phoneCode}${_phoneController.text}'; + try { - final result = await accountModule.requestCode(fullPhone); - + final result = await accountModule.requestCode( + fullPhone, + ); + if (mounted) { Navigator.push( - context, + screenContext, MaterialPageRoute( builder: (context) => CodeConfirmationScreen( - phoneNumber: '${_selectedCountry.phoneCode} $formattedPhone', + phoneNumber: + '${_selectedCountry.phoneCode} $formattedPhone', token: result.token, ), ), @@ -393,7 +417,7 @@ class _LoginScreenState extends State { } } catch (e) { if (mounted) { - showCustomNotification(context, 'Ошибка: $e'); + _showPhoneError(e.toString()); } } }, @@ -719,7 +743,28 @@ class _LoginScreenState extends State { ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 8), + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + alignment: Alignment.topLeft, + child: _phoneError != null + ? Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + _phoneError!, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ) + : const SizedBox.shrink(), + ), + const SizedBox(height: 16), TextButton( onPressed: () => _showOtherLoginMethods(context), child: Text( diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index faf6406..9a99a42 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -2,16 +2,13 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import '../chats/chat_list_screen.dart'; import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; class Password2FAScreen extends StatefulWidget { final String trackId; final String? hint; - const Password2FAScreen({ - super.key, - required this.trackId, - this.hint, - }); + const Password2FAScreen({super.key, required this.trackId, this.hint}); @override State createState() => _Password2FAScreenState(); @@ -43,8 +40,7 @@ class _Password2FAScreenState extends State { if (!mounted) return; - // После успешной 2FA делаем login - final loginResult = await accountModule.login(); + await accountModule.login(); if (!mounted) return; @@ -60,9 +56,7 @@ class _Password2FAScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Неверный пароль: $e')), - ); + showCustomNotification(context, 'Неверный пароль: $e'); } } @@ -131,7 +125,9 @@ class _Password2FAScreenState extends State { ), suffixIcon: IconButton( icon: Icon( - _isPasswordVisible ? Icons.visibility_off : Icons.visibility, + _isPasswordVisible + ? Icons.visibility_off + : Icons.visibility, color: cs.onSurfaceVariant, ), onPressed: () { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 678ac56..451322a 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'dart:math'; @@ -9,6 +10,10 @@ import 'chat_screen.dart'; import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; import '../profile/settings_tab.dart'; +import '../../../backend/api.dart'; +import '../../../backend/modules/chats.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../main.dart' show api; class ChatListScreen extends StatefulWidget { const ChatListScreen({super.key}); @@ -25,7 +30,12 @@ class _ChatListScreenState extends State late AnimationController _fabController; final Set _selectedChats = {}; final ScrollController _scrollController = ScrollController(); - double _pullRatio = 0.0; // 0.0 = folded (hidden row), 1.0 = fully expanded + double _pullRatio = 0.0; + ProfileData? _profile; + List _chats = []; + SessionState _sessionState = SessionState.disconnected; + StreamSubscription? _stateSub; + bool _shouldCollapseSearch = false; bool get _isSelectionMode => _selectedChats.isNotEmpty; @@ -36,12 +46,21 @@ class _ChatListScreenState extends State } else { _selectedChats.add(chatId); } + + if (_isSelectionMode) { + if (_scrollController.hasClients && _scrollController.offset < 132) { + _shouldCollapseSearch = true; + } + } else { + _shouldCollapseSearch = false; + } }); } void _clearSelection() { setState(() { _selectedChats.clear(); + _shouldCollapseSearch = false; }); } @@ -53,11 +72,45 @@ class _ChatListScreenState extends State duration: const Duration(milliseconds: 350), ); _scrollController.addListener(_onScroll); + + _sessionState = api.state; + _stateSub = api.stateStream.listen((state) { + if (mounted) setState(() => _sessionState = state); + }); + + _loadProfile(); + } + + Future _loadProfile() async { + final p = await AppDatabase.loadActiveProfile(); + if (p != null) { + final chats = await ChatsModule.getChats(p.id); + if (mounted) { + setState(() { + _profile = p; + _chats = chats; + }); + } + } + } + + String _formatTime(int? timestamp) { + if (timestamp == null || timestamp == 0) return ''; + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final h = dt.hour.toString().padLeft(2, '0'); + final m = dt.minute.toString().padLeft(2, '0'); + return '$h:$m'; } void _onScroll() { if (_scrollController.hasClients) { final double offset = _scrollController.offset; + if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) { + setState(() { + _shouldCollapseSearch = true; + }); + } + if (offset < 0) { final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); if (newRatio != _pullRatio) { @@ -75,6 +128,7 @@ class _ChatListScreenState extends State @override void dispose() { + _stateSub?.cancel(); _fabController.dispose(); _scrollController.dispose(); super.dispose(); @@ -133,9 +187,9 @@ class _ChatListScreenState extends State child: AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, - height: _isSelectionMode + height: _shouldCollapseSearch ? 0 - : (132 + (96 * _pullRatio)), + : (100 + (96 * _pullRatio)), color: Colors.transparent, clipBehavior: Clip.hardEdge, child: AnimatedContainer( @@ -143,7 +197,7 @@ class _ChatListScreenState extends State curve: Curves.easeOutCubic, transform: Matrix4.translationValues( 0, - _isSelectionMode ? -100 : 0, + _shouldCollapseSearch ? -100 : 0, 0, ), child: Column( @@ -153,7 +207,7 @@ class _ChatListScreenState extends State 20, 12, 20, - 4, + 2, ), child: Row( mainAxisAlignment: @@ -189,7 +243,9 @@ class _ChatListScreenState extends State ), ), Text( - 'Подключение...', + _sessionState == SessionState.online + ? (_profile?.firstName ?? 'Чат') + : 'Подключение...', style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -266,9 +322,9 @@ class _ChatListScreenState extends State Padding( padding: const EdgeInsets.fromLTRB( 20, - 4, + 2, 20, - 12, + 8, ), child: Container( height: 44, @@ -316,9 +372,7 @@ class _ChatListScreenState extends State ), ), SliverPadding( - padding: EdgeInsets.only( - top: _isSelectionMode ? 64 : 0, - ), + padding: EdgeInsets.zero, sliver: SliverToBoxAdapter( child: AnimatedContainer( duration: const Duration(milliseconds: 300), @@ -337,7 +391,7 @@ class _ChatListScreenState extends State scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: 20, - vertical: 8, + vertical: 4, ), physics: const BouncingScrollPhysics(), children: [ @@ -363,53 +417,24 @@ class _ChatListScreenState extends State ), ), SliverList( - delegate: SliverChildListDelegate([ - _buildChatItem( - 'stas', - 'Станислав', - 'Хорошо', - '10:07', - 'https://i.pravatar.cc/150?u=stas', - isOnline: true, - isRead: true, - ), - _buildChatItem( - 'ilya', - 'Илья', - 'печатает...', - '10:07', - 'https://i.pravatar.cc/150?u=ilya', - isOnline: true, - isTyping: true, - unreadCount: 1, - ), - _buildChatItem( - 'veronika', - 'Вероника', - 'Спасибо', - '09:56', - 'https://i.pravatar.cc/150?u=veronika', - isRead: true, - ), - _buildChatItem( - 'komet', - 'Komet Client', - 'Кстати. Смотрите, какую шту...', - '09:56', - 'https://i.pravatar.cc/150?u=komet', - unreadCount: 5, - isMuted: true, - ), - _buildChatItem( - 'podezd', - '4-й подъезд', - 'Людмила: Сколько?', - '09:34', - 'https://i.pravatar.cc/150?u=podezd', - unreadCount: 78, - isMuted: true, - ), - ]), + delegate: SliverChildBuilderDelegate((context, index) { + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, childCount: _chats.length), + ), + const SliverPadding( + padding: EdgeInsets.only(bottom: 120), ), ], ), // CustomScrollView @@ -532,7 +557,7 @@ class _ChatListScreenState extends State ), ), ), - if (!_isSelectionMode) ...[ + if (!_isSelectionMode && _currentNavIndex == 0) ...[ if (_fabController.value > 0) Positioned( right: 20, @@ -726,7 +751,22 @@ class _ChatListScreenState extends State ), leading: Stack( children: [ - CircleAvatar(radius: 24, backgroundImage: NetworkImage(imageUrl)), + CircleAvatar( + radius: 24, + backgroundColor: cs.surfaceContainerHighest, + backgroundImage: imageUrl.isNotEmpty + ? NetworkImage(imageUrl) + : null, + child: imageUrl.isEmpty + ? Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 20, + ), + ) + : null, + ), if (isSelected) Positioned( right: -2, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 50f586d..6d2715b 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -58,10 +58,20 @@ class _ChatScreenState extends State { titleSpacing: 0, title: Row( children: [ - CircleAvatar( - radius: 18, - backgroundImage: NetworkImage(widget.imageUrl), - ), + if (widget.imageUrl.isNotEmpty) + CircleAvatar( + radius: 18, + backgroundImage: NetworkImage(widget.imageUrl), + ) + else + CircleAvatar( + radius: 18, + backgroundColor: Colors.blueGrey, + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), const SizedBox(width: 12), Expanded( child: Column( @@ -111,11 +121,17 @@ class _ChatScreenState extends State { body: Stack( children: [ Positioned.fill( - child: Image.network( - 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809', - fit: BoxFit.cover, - opacity: const AlwaysStoppedAnimation(0.4), - ), + child: widget.imageUrl.isNotEmpty + ? Image.network( + widget.imageUrl, + fit: BoxFit.cover, + opacity: const AlwaysStoppedAnimation(0.4), + ) + : Image.network( + 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809', + fit: BoxFit.cover, + opacity: const AlwaysStoppedAnimation(0.4), + ), ), const Positioned.fill( child: DecoratedBox( diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 95e1f6a..05ca27c 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,12 +1,42 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; -class SettingsTab extends StatelessWidget { +class SettingsTab extends StatefulWidget { const SettingsTab({super.key}); + @override + State createState() => _SettingsTabState(); +} + +class _SettingsTabState extends State { + ProfileData? _profile; + bool _isPhoneVisible = false; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + Future _loadProfile() async { + final p = await AppDatabase.loadActiveProfile(); + if (mounted) setState(() => _profile = p); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + + if (_profile == null) { + return const Center(child: CircularProgressIndicator()); + } + + final String fullName = + '${_profile!.firstName}${_profile!.lastName != null ? ' ${_profile!.lastName}' : ''}'; + final String phone = '+${_profile!.phone}'; + return Scaffold( backgroundColor: cs.surface, body: SafeArea( @@ -14,7 +44,9 @@ class SettingsTab extends StatelessWidget { child: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ - SliverToBoxAdapter(child: _buildHeader(context, cs)), + SliverToBoxAdapter( + child: _buildHeader(context, cs, fullName, phone), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), @@ -25,7 +57,7 @@ class SettingsTab extends StatelessWidget { _SettingsItem(icon: Symbols.badge, label: 'Цифровой ID'), _SettingsItem( icon: Symbols.language, - label: 'Войти в сферум', + label: 'Войти в Сферум', ), ], ), @@ -55,7 +87,12 @@ class SettingsTab extends StatelessWidget { ); } - Widget _buildHeader(BuildContext context, ColorScheme cs) { + Widget _buildHeader( + BuildContext context, + ColorScheme cs, + String name, + String phone, + ) { return Padding( padding: const EdgeInsets.fromLTRB(8, 12, 8, 20), child: Column( @@ -95,23 +132,19 @@ class SettingsTab extends StatelessWidget { ), ), child: ClipOval( - child: Image.network( - 'https://i.pravatar.cc/150?u=ilya', - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => CircleAvatar( - backgroundColor: cs.primaryContainer, - child: Icon( - Symbols.person, - color: cs.onPrimaryContainer, - size: 40, - ), - ), - ), + child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty + ? Image.network( + _profile!.baseUrl!, + fit: BoxFit.cover, + errorBuilder: (context, _, __) => + _buildPlaceholderAvatar(cs, name), + ) + : _buildPlaceholderAvatar(cs, name), ), ), const SizedBox(height: 14), Text( - 'Илья Беларуских', + name, style: TextStyle( color: cs.onSurface, fontSize: 20, @@ -119,39 +152,70 @@ class SettingsTab extends StatelessWidget { fontFamily: 'Outfit', ), ), - const SizedBox(height: 3), - Text( - '@everrnyan', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: _PhoneSpoiler( + text: phone, + isVisible: _isPhoneVisible, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.5, + ), + ), + ), + ), + const SizedBox(width: 4), + Icon( + _isPhoneVisible ? Symbols.visibility : Symbols.visibility_off, + size: 14, + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + ), + ], ), ], ), ); } + Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { + return Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + ); + } + Widget _buildSection( BuildContext context, ColorScheme cs, { required List<_SettingsItem> items, }) { - return ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Container( - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - ), - child: Column( - children: List.generate(items.length, (index) { - final item = items[index]; - final isLast = index == items.length - 1; - return _buildSettingsRow(context, cs, item, isLast: isLast); - }), - ), + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: List.generate(items.length, (index) { + final item = items[index]; + final isLast = index == items.length - 1; + return _buildSettingsRow(context, cs, item, isLast: isLast); + }), ), ); } @@ -168,6 +232,9 @@ class SettingsTab extends StatelessWidget { color: Colors.transparent, child: InkWell( onTap: () {}, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : null, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( @@ -220,3 +287,93 @@ class _SettingsItem { const _SettingsItem({required this.icon, required this.label}); } + +class _PhoneSpoiler extends StatefulWidget { + final String text; + final bool isVisible; + final TextStyle style; + + const _PhoneSpoiler({ + required this.text, + required this.isVisible, + required this.style, + }); + + @override + State<_PhoneSpoiler> createState() => _PhoneSpoilerState(); +} + +class _PhoneSpoilerState extends State<_PhoneSpoiler> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: widget.isVisible + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: SizedBox( + child: CustomPaint( + size: const Size(110, 16), + painter: _SpoilerPainter(_controller, widget.style.color!), + ), + ), + secondChild: Text(widget.text, style: widget.style), + ); + } +} + +class _SpoilerPainter extends CustomPainter { + final Animation animation; + final Color color; + + _SpoilerPainter(this.animation, this.color) : super(repaint: animation); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color.withValues(alpha: 0.15) + ..style = PaintingStyle.fill; + + // Draw the background + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width, size.height), + const Radius.circular(4), + ), + paint, + ); + + // Draw "noisy" particles + final particlePaint = Paint()..style = PaintingStyle.fill; + + // Simple noise effect with dots using animation value for movement + for (int i = 0; i < 60; i++) { + double dx = (i * 17.5 + animation.value * 20) % size.width; + double dy = (i * 13.7 + animation.value * 15) % size.height; + double opacity = (0.2 + 0.3 * (i % 5) / 5.0).clamp(0.0, 1.0); + particlePaint.color = color.withValues(alpha: opacity); + canvas.drawCircle(Offset(dx, dy), 1.2, particlePaint); + } + } + + @override + bool shouldRepaint(_SpoilerPainter oldDelegate) => true; +} diff --git a/lib/main.dart b/lib/main.dart index a50de9b..967727c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,9 @@ import 'package:google_fonts/google_fonts.dart'; import 'backend/api.dart'; import 'backend/modules/account.dart'; import 'core/storage/app_database.dart'; +import 'core/storage/token_storage.dart'; import 'frontend/screens/auth/login_screen.dart'; +import 'frontend/screens/chats/chat_list_screen.dart'; final api = Api(); final accountModule = AccountModule(api); @@ -42,10 +44,12 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - final baseScheme = darkDynamic ?? ColorScheme.fromSeed( - seedColor: _fallbackSeed, - brightness: Brightness.dark, - ); + final baseScheme = + darkDynamic ?? + ColorScheme.fromSeed( + seedColor: _fallbackSeed, + brightness: Brightness.dark, + ); final darkScheme = _adjustScheme(baseScheme); @@ -55,13 +59,66 @@ class MyApp extends StatelessWidget { theme: ThemeData( useMaterial3: true, colorScheme: darkScheme, - textTheme: GoogleFonts.interTextTheme( - ThemeData.dark().textTheme, - ), + textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme), ), - home: const LoginScreen(), + home: const _StartupScreen(), ); }, ); } } + +class _StartupScreen extends StatefulWidget { + const _StartupScreen(); + + @override + State<_StartupScreen> createState() => _StartupScreenState(); +} + +class _StartupScreenState extends State<_StartupScreen> { + @override + void initState() { + super.initState(); + _tryAutoLogin(); + } + + Future _tryAutoLogin() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + _goToLogin(); + return; + } + + try { + await accountModule.login(accountId: accountId); + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const ChatListScreen()), + ); + } + } catch (_) { + _goToLogin(); + } + } + + void _goToLogin() { + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: Center( + child: CircularProgressIndicator(color: cs.primary, strokeWidth: 2), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 8948ca6..64ede3c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -300,18 +300,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -601,10 +601,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: From 91d2afbb57c8eaf10cc9d21b74d582b899b4989d Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 3 Apr 2026 20:32:22 +0700 Subject: [PATCH 02/59] =?UTF-8?q?=D0=BF=D1=8F=D1=82=D0=BA=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=86=D0=B5=D0=BB=D1=83=D0=B9=D1=82=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=85=D1=83=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 4 +- lib/backend/modules/messages.dart | 163 +++++++++++++++ lib/core/protocol/packet.dart | 111 +++++----- lib/core/storage/app_database.dart | 62 +++++- lib/core/transport/receiver.dart | 16 +- .../screens/chats/chat_list_screen.dart | 6 +- lib/frontend/screens/chats/chat_screen.dart | 197 +++++++++++++----- lib/main.dart | 2 + 8 files changed, 446 insertions(+), 115 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 59efde3..1eeb9dd 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -199,8 +199,8 @@ class Api { logger.i('Сессия: ${state.name}'); } - void _onDataReceived(Uint8List data) { - for (final packet in _receiver.feed(data)) { + Future _onDataReceived(Uint8List data) async { + await for (final packet in _receiver.feed(data)) { _dispatcher.dispatch(packet); } } diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e69de29..61d7614 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/storage/app_database.dart'; + +class CachedMessage { + final String id; + final int accountId; + final int chatId; + final int senderId; + final String? text; + final int time; + final String? status; + final Map? payload; + + const CachedMessage({ + required this.id, + required this.accountId, + required this.chatId, + required this.senderId, + this.text, + required this.time, + this.status, + this.payload, + }); + + factory CachedMessage.fromDbRow(Map row) { + Map? payload; + final payloadRaw = row['payload']; + if (payloadRaw is String && payloadRaw.isNotEmpty) { + try { + payload = jsonDecode(payloadRaw) as Map; + } catch (_) {} + } + + return CachedMessage( + id: row['id'] as String, + accountId: row['account_id'] as int, + chatId: row['chat_id'] as int, + senderId: row['sender_id'] as int, + text: row['text'] as String?, + time: row['time'] as int, + status: row['status'] as String?, + payload: payload, + ); + } + + Map toDbRow() => { + 'id': id, + 'account_id': accountId, + 'chat_id': chatId, + 'sender_id': senderId, + 'text': text, + 'time': time, + 'status': status, + 'payload': payload != null ? jsonEncode(payload) : null, + }; +} + +class MessagesModule { + final Api _api; + + MessagesModule(this._api); + + /// Загружает историю сообщений для указанного чата. + /// + /// [fromTime] — опционально, время от которого грузить (миллисекунды). + /// Если не указано, грузит самые свежие. + /// [count] — количество сообщений. + Future> fetchHistory( + int accountId, + int chatId, { + int? fromTime, + int count = 50, + }) async { + final payload = { + 'chatId': chatId, + 'from': + fromTime ?? + (DateTime.now().millisecondsSinceEpoch + + 86400000), // +1 день для запаса + 'forward': 0, + 'backward': count, + 'getMessages': true, + }; + + final response = await _api.sendRequest(Opcode.chatHistory, payload); + + if (!response.isOk) return []; + + final data = response.payload; + if (data is! Map) return []; + + final messagesData = data['messages']; + if (messagesData is! List) return []; + + final List results = []; + final List> rows = []; + + // Обрабатываем сообщения "кусочками", чтобы не фризить UI при маппинге большого количества данных + for (var i = 0; i < messagesData.length; i++) { + final m = messagesData[i]; + if (m is! Map) continue; + + final msg = _parseMessage(m.cast(), accountId, chatId); + if (msg != null) { + results.add(msg); + rows.add(msg.toDbRow()); + } + + // Каждые 20 сообщений даем UI-потоку "дохнуть" и отрисовать кадр + if (i > 0 && i % 20 == 0) { + await Future.delayed(Duration.zero); + } + } + + if (rows.isNotEmpty) { + AppDatabase.saveMessages(rows).ignore(); + } + + return results; + } + + /// Загружает сообщения из локальной базы данных. + Future> getLocalHistory( + int accountId, + int chatId, { + int limit = 50, + int offset = 0, + }) async { + final rows = await AppDatabase.loadMessages( + accountId, + chatId, + limit: limit, + offset: offset, + ); + return rows.map(CachedMessage.fromDbRow).toList(); + } + + CachedMessage? _parseMessage( + Map m, + int accountId, + int chatId, + ) { + final id = m['id']?.toString(); + if (id == null) return null; + + return CachedMessage( + id: id, + accountId: accountId, + chatId: chatId, + senderId: (m['sender'] as int?) ?? 0, + text: m['text'] as String?, + time: (m['time'] as int?) ?? 0, + status: m['status'] as String?, + payload: m + .cast< + String, + dynamic + >(), // Сохраняем весь пакет для гибкости (аттачи и т.д.) + ); + } +} diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 3455d03..18658da 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'dart:isolate'; import 'package:dart_lz4/dart_lz4.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; @@ -69,71 +70,75 @@ Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { } /// Распаковка пакета от сервера -Packet unpackPacket(Uint8List packet) { - // Для удобства расшифровки пакета переводим в ByteData - ByteData packetData = ByteData.view( - packet.buffer, - packet.offsetInBytes, - packet.lengthInBytes, - ); +Future unpackPacket(Uint8List packet) async { + return Isolate.run(() { + // Для удобства расшифровки пакета переводим в ByteData + ByteData packetData = ByteData.view( + packet.buffer, + packet.offsetInBytes, + packet.lengthInBytes, + ); - // Объяснение каждой переменной смотри в классе Packet + // API версия и cmd представляют из себя 8 битные числа + final apiVer = packetData.getUint8(0) & 0xFF; + final cmd = packetData.getUint8(1) & 0xFF; - // API версия и cmd представляют из себя 8 битные числа - final apiVer = packetData.getUint8(0) & 0xFF; - final cmd = packetData.getUint8(1) & 0xFF; + // Sequence и OPCode представляют из себя 16 битные числа + final seq = packetData.getUint16(2) & 0xFFFF; + final opcode = packetData.getUint16(4) & 0xFFFF; - // Sequence и OPCode представляют из себя 16 битные числа - final seq = packetData.getUint16(2) & 0xFFFF; - final opcode = packetData.getUint16(4) & 0xFFFF; + // После базовых переменных идет длина пакета, является 32 битным числом + final packedLen = packetData.getUint32(6); - // После базовых переменных идет длина пакета, является 32 битным числом - final packedLen = packetData.getUint32(6); + // Compression flag показывает, сжат ли payload + final compFlag = packedLen >> 24; - // Compression flag показывает, сжат ли payload - final compFlag = packedLen >> 24; - - // Длина payload'а - final payloadLength = packedLen & 0xFFFFFF; + // Длина payload'а + final payloadLength = packedLen & 0xFFFFFF; - // Байты payload'а, могут быть сжаты LZ4 - var payloadBytes = packet.buffer.asUint8List(10, payloadLength); + // Байты payload'а, могут быть сжаты LZ4 + var payloadBytes = packet.buffer.asUint8List(10, payloadLength); - dynamic payload; - - if (payloadBytes.isNotEmpty) { - if (compFlag != 0) { - try { - payloadBytes = lz4Decompress( - payloadBytes, - decompressedSize: _maxDecompressedSize, - ); - - } catch (_) { + dynamic payload; + + if (payloadBytes.isNotEmpty) { + if (compFlag != 0) { try { - payloadBytes = _lz4BlockDecompress(payloadBytes, _maxDecompressedSize); - } catch (e) { - logger.e("LZ4 decompression error: $e", error: e); + payloadBytes = lz4Decompress( + payloadBytes, + decompressedSize: _maxDecompressedSize, + ); + } catch (_) { + try { + payloadBytes = _lz4BlockDecompress( + payloadBytes, + _maxDecompressedSize, + ); + } catch (e) { + // В изоляте нельзя использовать логгер, который пишет в терминал через зависимости Flutter, + // но простой print или throw сработает + print("LZ4 decompression error: $e"); + } + } + } + + try { + payload = msgpack.deserialize(payloadBytes); + } catch (e) { + if (payloadBytes.isNotEmpty) { + print("MsgPack deserialization error: $e"); } } } - try { - payload = msgpack.deserialize(payloadBytes); - } catch (e) { - if (payloadBytes.isNotEmpty) { - logger.e("MsgPack deserialization error: $e", error: e); - } - } - } - - return Packet( - api: apiVer, - cmd: cmd, - seq: seq, - opcode: opcode, - payload: payload, - ); + return Packet( + api: apiVer, + cmd: cmd, + seq: seq, + opcode: opcode, + payload: payload, + ); + }); } /// LZ4 block декомпрессия (без frame-заголовка). diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 7f5af5f..97b80a9 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -120,7 +120,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 5, + version: 6, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -141,6 +141,9 @@ class AppDatabase { await db.execute('DROP TABLE IF EXISTS chats_cache'); await db.execute(_chatsCacheSchema); } + if (oldVersion < 6) { + await db.execute(_messagesSchema); + } }, ); } @@ -164,6 +167,7 @@ class AppDatabase { await db.execute(_syncStateSchema); await db.execute(_chatsCacheSchema); await db.execute(_contactsSchema); + await db.execute(_messagesSchema); } static const _contactsSchema = ''' @@ -211,6 +215,21 @@ class AppDatabase { ) '''; + static const _messagesSchema = ''' + CREATE TABLE messages ( + id TEXT NOT NULL, + account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + chat_id INTEGER NOT NULL, + sender_id INTEGER NOT NULL, + text TEXT, + time INTEGER NOT NULL, + status TEXT, + payload TEXT, + PRIMARY KEY (id, account_id), + FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE + ) + '''; + static Future saveProfile(ProfileData profile) async { final db = await _instance; await db.insert( @@ -360,4 +379,45 @@ class AppDatabase { whereArgs: [accountId], ); } + + static Future saveMessages(List> rows) async { + final db = await _instance; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + 'messages', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + } + + static Future>> loadMessages( + int accountId, + int chatId, { + int? limit, + int? offset, + }) async { + final db = await _instance; + return db.query( + 'messages', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + orderBy: 'time DESC', + limit: limit, + offset: offset, + ); + } + + static Future clearMessages(int accountId, int chatId) async { + final db = await _instance; + await db.delete( + 'messages', + where: 'account_id = ? AND chat_id = ?', + whereArgs: [accountId, chatId], + ); + } } diff --git a/lib/core/transport/receiver.dart b/lib/core/transport/receiver.dart index 25af729..a14476e 100644 --- a/lib/core/transport/receiver.dart +++ b/lib/core/transport/receiver.dart @@ -10,22 +10,22 @@ class PacketReceiver { static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта - /// Добавляет байты в буфер, возвращает все собранные пакеты. + /// Добавляет байты в буфер, возвращает поток собранных пакетов. /// Неполные данные остаются в буфере до следующего вызова. - List feed(Uint8List data) { + Stream feed(Uint8List data) async* { final newBuffer = Uint8List(_buffer.length + data.length); newBuffer.setAll(0, _buffer); newBuffer.setAll(_buffer.length, data); _buffer = newBuffer; if (_buffer.length > _maxBufferSize) { - logger.e('PacketReceiver: переполнение буфера (${_buffer.length} B), сброс'); + logger.e( + 'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс', + ); reset(); - return []; + return; } - final packets = []; - while (_buffer.length >= headerSize) { final bd = ByteData.view( _buffer.buffer, @@ -42,13 +42,11 @@ class PacketReceiver { _buffer = _buffer.sublist(totalLength); try { - packets.add(unpackPacket(packetBytes)); + yield await unpackPacket(packetBytes); } catch (e) { logger.e('PacketReceiver: ошибка распаковки: $e'); } } - - return packets; } void reset() { diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 451322a..cda1a0b 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -735,7 +735,11 @@ class _ChatListScreenState extends State Navigator.push( context, MaterialPageRoute( - builder: (context) => ChatScreen(name: name, imageUrl: imageUrl), + builder: (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + ), ), ); } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 6d2715b..d5484b2 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,31 +1,63 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'dart:ui'; +import '../../../main.dart'; +import '../../../core/storage/app_database.dart'; class ChatScreen extends StatefulWidget { + final int chatId; final String name; final String imageUrl; - const ChatScreen({super.key, required this.name, required this.imageUrl}); + const ChatScreen({ + super.key, + required this.chatId, + required this.name, + required this.imageUrl, + }); @override State createState() => _ChatScreenState(); } -class _ChatScreenState extends State { +class _ChatScreenState extends State + with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); bool _hasText = false; + bool _isLoading = true; + late AnimationController _shimmerController; @override void initState() { super.initState(); _messageController.addListener(_onTextChanged); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadHistory(); + } + + Future _loadHistory() async { + try { + final activeProfile = await AppDatabase.loadActiveProfile(); + final myId = activeProfile?.id ?? 0; + await messagesModule.fetchHistory(myId, widget.chatId); + } catch (e) { + debugPrint('Background history fetch failed: $e'); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } } @override void dispose() { _messageController.removeListener(_onTextChanged); _messageController.dispose(); + _shimmerController.dispose(); super.dispose(); } @@ -42,9 +74,8 @@ class _ChatScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, - extendBodyBehindAppBar: true, appBar: AppBar( - backgroundColor: const Color(0xFF1B1B1B).withOpacity(0.8), + backgroundColor: const Color(0xFF1B1B1B), elevation: 0, surfaceTintColor: Colors.transparent, leading: IconButton( @@ -77,21 +108,17 @@ class _ChatScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text( - widget.name, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], + Text( + widget.name, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), ), const Text( - 'Connecting...', + 'last seen recently', style: TextStyle( color: Colors.grey, fontSize: 12, @@ -118,38 +145,112 @@ class _ChatScreenState extends State { ), ], ), - body: Stack( + body: Column( children: [ - Positioned.fill( - child: widget.imageUrl.isNotEmpty - ? Image.network( - widget.imageUrl, - fit: BoxFit.cover, - opacity: const AlwaysStoppedAnimation(0.4), - ) - : Image.network( - 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809', - fit: BoxFit.cover, - opacity: const AlwaysStoppedAnimation(0.4), - ), + Expanded( + child: _isLoading + ? _buildShimmerLoading() + : const SizedBox.shrink(), ), - const Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black87], - ), - ), - ), - ), - Column(children: [const Spacer(), _buildInputArea(context)]), + _buildInputArea(context), ], ), ); } + Widget _buildShimmerLoading() { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + (0.4 * _shimmerController.value); + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 8, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final hasImage = index % 3 == 0; + final hasReactions = index % 2 == 0; + final width1 = 60.0 + (index * 15 % 50); + final width2 = 120.0 + (index * 25 % 80); + + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Color(0xFF2B2B2B), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: width1, + height: 10, + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox(height: 6), + Container( + width: width2, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(10), + ), + ), + if (hasImage) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + height: 120, + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(12), + ), + ), + ], + if (hasReactions) ...[ + const SizedBox(height: 8), + Row( + children: List.generate( + 3, + (i) => Container( + width: 32, + height: 16, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: const Color(0xFF2B2B2B), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + Widget _buildInputArea(BuildContext context) { return SafeArea( child: Padding( @@ -160,7 +261,6 @@ class _ChatScreenState extends State { Expanded( child: AnimatedContainer( duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, constraints: const BoxConstraints( minHeight: 54, maxHeight: 180, @@ -173,10 +273,7 @@ class _ChatScreenState extends State { width: 0.5, ), ), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 0, - ), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -205,7 +302,9 @@ class _ChatScreenState extends State { ), border: InputBorder.none, isDense: true, - contentPadding: EdgeInsets.zero, + contentPadding: EdgeInsets.symmetric( + vertical: 14, + ), // Выравниваем по Y ), ), ), diff --git a/lib/main.dart b/lib/main.dart index 967727c..a4dc755 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'backend/api.dart'; import 'backend/modules/account.dart'; +import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; import 'frontend/screens/auth/login_screen.dart'; @@ -10,6 +11,7 @@ import 'frontend/screens/chats/chat_list_screen.dart'; final api = Api(); final accountModule = AccountModule(api); +final messagesModule = MessagesModule(api); void main() async { WidgetsFlutterBinding.ensureInitialized(); From bb9a4732b7a4d0ed41ac68a2f2024b995a3305ba Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 3 Apr 2026 20:54:12 +0700 Subject: [PATCH 03/59] =?UTF-8?q?=D0=B1=D0=BB=D1=8F=20=D1=8D=D1=82=D0=BE?= =?UTF-8?q?=20=D1=81=D1=8D=D0=BA=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 2 - lib/core/protocol/packet.dart | 2 - .../screens/chats/chat_list_screen.dart | 139 +++++++++++++++++- lib/frontend/screens/chats/chat_screen.dart | 44 +++++- lib/main.dart | 19 ++- 5 files changed, 182 insertions(+), 24 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 61d7614..2cc90a7 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -97,7 +97,6 @@ class MessagesModule { final List results = []; final List> rows = []; - // Обрабатываем сообщения "кусочками", чтобы не фризить UI при маппинге большого количества данных for (var i = 0; i < messagesData.length; i++) { final m = messagesData[i]; if (m is! Map) continue; @@ -108,7 +107,6 @@ class MessagesModule { rows.add(msg.toDbRow()); } - // Каждые 20 сообщений даем UI-потоку "дохнуть" и отрисовать кадр if (i > 0 && i % 20 == 0) { await Future.delayed(Duration.zero); } diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 18658da..2cb5768 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -3,8 +3,6 @@ import 'dart:isolate'; import 'package:dart_lz4/dart_lz4.dart'; import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; -import '../utils/logger.dart'; - /// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10 const int headerSize = 10; const int _maxDecompressedSize = 1048576; // 1 MB diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index cda1a0b..fd1b757 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -23,10 +23,11 @@ class ChatListScreen extends StatefulWidget { } class _ChatListScreenState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { String _selectedCategory = 'Все чаты'; int _currentNavIndex = 0; bool _isFabOpen = false; + bool _showCacheWarning = false; late AnimationController _fabController; final Set _selectedChats = {}; final ScrollController _scrollController = ScrollController(); @@ -64,6 +65,9 @@ class _ChatListScreenState extends State }); } + bool _isInitialLoading = true; + late AnimationController _shimmerController; + @override void initState() { super.initState(); @@ -71,11 +75,26 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _scrollController.addListener(_onScroll); _sessionState = api.state; _stateSub = api.stateStream.listen((state) { - if (mounted) setState(() => _sessionState = state); + if (mounted) { + setState(() { + _sessionState = state; + if (state == SessionState.disconnected && _chats.isNotEmpty) { + _showCacheWarning = true; + } + if (state == SessionState.online) { + _showCacheWarning = false; + } + }); + } }); _loadProfile(); @@ -89,6 +108,13 @@ class _ChatListScreenState extends State setState(() { _profile = p; _chats = chats; + _isInitialLoading = false; + }); + } + } else { + if (mounted) { + setState(() { + _isInitialLoading = false; }); } } @@ -102,6 +128,59 @@ class _ChatListScreenState extends State return '$h:$m'; } + Widget _buildChatShimmer() { + final cs = Theme.of(context).colorScheme; + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 120, + height: 14, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(7), + ), + ), + const SizedBox(height: 10), + Container( + width: double.infinity, + height: 10, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(5), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + void _onScroll() { if (_scrollController.hasClients) { final double offset = _scrollController.offset; @@ -319,6 +398,49 @@ class _ChatListScreenState extends State ), ), ), + 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.withOpacity( + 0.3, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.error.withOpacity(0.2), + ), + ), + child: Row( + children: [ + Icon( + Symbols.cloud_off, + size: 18, + color: cs.error, + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Ошибка соединения, сейчас вы смотрите КЕШ', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), Padding( padding: const EdgeInsets.fromLTRB( 20, @@ -418,6 +540,9 @@ class _ChatListScreenState extends State ), SliverList( delegate: SliverChildBuilderDelegate((context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } final chat = _chats[index]; return _buildChatItem( chat.id.toString(), @@ -431,10 +556,10 @@ class _ChatListScreenState extends State unreadCount: chat.unreadCount, isMuted: chat.dontDisturbUntil > 0, ); - }, childCount: _chats.length), + }, childCount: _isInitialLoading ? 10 : _chats.length), ), const SliverPadding( - padding: EdgeInsets.only(bottom: 120), + padding: EdgeInsets.only(bottom: 100), ), ], ), // CustomScrollView @@ -449,7 +574,7 @@ class _ChatListScreenState extends State curve: Curves.easeOutCubic, left: 8, right: 8, - bottom: _isSelectionMode ? -100 : 24.0, + bottom: _isSelectionMode ? -100 : 10.0, child: RepaintBoundary( child: Container( height: 68, @@ -561,7 +686,7 @@ class _ChatListScreenState extends State if (_fabController.value > 0) Positioned( right: 20, - bottom: 110 + 74, + bottom: 90 + 74, child: RepaintBoundary( child: Transform.scale( scale: val, @@ -575,7 +700,7 @@ class _ChatListScreenState extends State ), Positioned( right: 20, - bottom: 110, + bottom: 90, child: FloatingActionButton( onPressed: _toggleFab, backgroundColor: cs.primaryContainer, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index d5484b2..ab8c985 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,6 +1,9 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; +import '../../../backend/api.dart'; +import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; class ChatScreen extends StatefulWidget { @@ -25,6 +28,7 @@ class _ChatScreenState extends State bool _hasText = false; bool _isLoading = true; late AnimationController _shimmerController; + List _messages = []; @override void initState() { @@ -34,17 +38,45 @@ class _ChatScreenState extends State vsync: this, duration: const Duration(milliseconds: 1500), )..repeat(); + _loadHistory(); } Future _loadHistory() async { + final activeProfile = await AppDatabase.loadActiveProfile(); + final myId = activeProfile?.id ?? 0; + + final cachedRows = await AppDatabase.loadMessages( + myId, + widget.chatId, + limit: 100, + ); + if (mounted && cachedRows.isNotEmpty) { + setState(() { + _messages = cachedRows.map((r) => CachedMessage.fromDbRow(r)).toList(); + if (api.state == SessionState.online) { + _isLoading = false; + } + }); + } + try { - final activeProfile = await AppDatabase.loadActiveProfile(); - final myId = activeProfile?.id ?? 0; await messagesModule.fetchHistory(myId, widget.chatId); + final updatedRows = await AppDatabase.loadMessages( + myId, + widget.chatId, + limit: 100, + ); + if (mounted) { + setState(() { + _messages = updatedRows + .map((r) => CachedMessage.fromDbRow(r)) + .toList(); + _isLoading = false; + }); + } } catch (e) { - debugPrint('Background history fetch failed: $e'); - } finally { + debugPrint('Error fetching history: $e'); if (mounted) { setState(() { _isLoading = false; @@ -148,7 +180,9 @@ class _ChatScreenState extends State body: Column( children: [ Expanded( - child: _isLoading + child: + _isLoading || + (_messages.isEmpty && api.state != SessionState.online) ? _buildShimmerLoading() : const SizedBox.shrink(), ), diff --git a/lib/main.dart b/lib/main.dart index a4dc755..c8fb446 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,16 +91,19 @@ class _StartupScreenState extends State<_StartupScreen> { return; } + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const ChatListScreen()), + ); + } + try { await accountModule.login(accountId: accountId); - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => const ChatListScreen()), - ); - } - } catch (_) { - _goToLogin(); + } catch (e) { + debugPrint( + 'Background auto-login failed (safe to ignore if offline): $e', + ); } } From b5a415292f77cff95745dc2afe866d9983d3a213 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 19:53:06 +0300 Subject: [PATCH 04/59] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=89=D0=B8=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BD=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B8=D0=B7=20=D1=81=D0=BC=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 12 +-- lib/core/protocol/packet.dart | 20 ++++ lib/core/transport/dispatcher.dart | 4 +- .../auth/code_confirmation_screen.dart | 5 +- macos/Podfile.lock | 55 +++++++++++ macos/Runner.xcodeproj/project.pbxproj | 98 ++++++++++++++++++- .../contents.xcworkspacedata | 3 + 7 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 macos/Podfile.lock diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index b8ff3c3..a9af71a 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -7,13 +7,6 @@ import '../../core/utils/logger.dart'; import 'chats.dart'; import 'contacts.dart'; -class ServerException implements Exception { - final String message; - const ServerException(this.message); - @override - String toString() => message; -} - enum AuthRequestType { startAuth('START_AUTH'), resend('RESEND'), @@ -447,10 +440,7 @@ class AccountModule { void _checkPacketError(Packet packet, String method) { if (packet.isError) { - final errMsg = packet.payload is Map - ? (packet.payload as Map)['message'] ?? packet.payload.toString() - : packet.payload?.toString() ?? 'Неизвестная ошибка'; - throw ServerException(errMsg.toString()); + throw PacketError(messageFromErrorPayload(packet.payload)); } } } diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 2cb5768..6970165 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -52,6 +52,26 @@ class Packet { 'Packet(ver=$api cmd=$cmd seq=$seq opcode=$opcode payload=$payload)'; } +class PacketError implements Exception { + final String message; + const PacketError(this.message); + @override + String toString() => message; +} + +String messageFromErrorPayload(dynamic payload) { + if (payload is Map) { + for (final key in ['localizedMessage', 'message', 'title']) { + final v = payload[key]; + if (v is String && v.trim().isNotEmpty) return v.trim(); + } + return 'Неизвестная ошибка'; + } + if (payload == null) return 'Неизвестная ошибка'; + final s = payload.toString(); + return s.isNotEmpty ? s : 'Неизвестная ошибка'; +} + /// Упаковка пакета для отправки на сервер Uint8List packPacket(int opcode, Map payload, {int seq = 0}) { final header = ByteData(headerSize); diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index 103f033..bb22136 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -68,7 +68,9 @@ class PacketDispatcher { } if (packet.isError) { - completer.completeError(packet); + completer.completeError( + PacketError(messageFromErrorPayload(packet.payload)), + ); } else { completer.complete(packet); } diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index f86f656..55339be 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -306,8 +306,9 @@ class _CodeConfirmationScreenState extends State _errorMessage!, style: TextStyle( color: cs.error, - fontSize: 13, - fontWeight: FontWeight.w400, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.35, ), ), ), diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..14765ba --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,55 @@ +PODS: + - device_info_plus (0.0.1): + - FlutterMacOS + - dynamic_color (0.0.2): + - FlutterMacOS + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + - flutter_timezone (0.1.0): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - dynamic_color (from `Flutter/ephemeral/.symlinks/plugins/dynamic_color/macos`) + - flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + +EXTERNAL SOURCES: + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + dynamic_color: + :path: Flutter/ephemeral/.symlinks/plugins/dynamic_color/macos + flutter_secure_storage_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin + flutter_timezone: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos + FlutterMacOS: + :path: Flutter/ephemeral + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + +SPEC CHECKSUMS: + device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 + dynamic_color: 5fdff3953fb3457311091863f72914fc76ea3209 + flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d + flutter_timezone: b3bc0c587d8780d395651284a1ff46eb1e5753ac + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 2110d8f..7188c51 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -21,12 +21,14 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 1B0AAF467EC25EF9159DC6E7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D452F147ED5EA5A4D3B2F69C /* Pods_Runner.framework */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 5891C53C95FBB6606F87B07B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC834A0EF5C5ACF230BCDB0F /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 1EEBCAF84AEC15439093B432 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 2F1D1037B18DFACF3DCB068D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* komet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "komet.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* komet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = komet.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -77,7 +81,13 @@ 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9429F298C2BDD78E966599A3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A30CA7B4319C9569917A735D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + BC834A0EF5C5ACF230BCDB0F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CC0F01C1D03006A1DD67A151 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + D452F147ED5EA5A4D3B2F69C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D9473A96CB79E1B69ACF81CC /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5891C53C95FBB6606F87B07B /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,12 +103,27 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1B0AAF467EC25EF9159DC6E7 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1DB0FC36071C177710C75E9D /* Pods */ = { + isa = PBXGroup; + children = ( + D9473A96CB79E1B69ACF81CC /* Pods-Runner.debug.xcconfig */, + A30CA7B4319C9569917A735D /* Pods-Runner.release.xcconfig */, + 2F1D1037B18DFACF3DCB068D /* Pods-Runner.profile.xcconfig */, + 9429F298C2BDD78E966599A3 /* Pods-RunnerTests.debug.xcconfig */, + 1EEBCAF84AEC15439093B432 /* Pods-RunnerTests.release.xcconfig */, + CC0F01C1D03006A1DD67A151 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 331C80D6294CF71000263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -125,6 +151,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 1DB0FC36071C177710C75E9D /* Pods */, ); sourceTree = ""; }; @@ -175,6 +202,8 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + D452F147ED5EA5A4D3B2F69C /* Pods_Runner.framework */, + BC834A0EF5C5ACF230BCDB0F /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 5D8614419BBD7BD81C481542 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + C78DF3AD05984D1177E03977 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + AAC6FCD26EB13D7261C78A3C /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -329,6 +361,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 5D8614419BBD7BD81C481542 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + AAC6FCD26EB13D7261C78A3C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C78DF3AD05984D1177E03977 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9429F298C2BDD78E966599A3 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1EEBCAF84AEC15439093B432 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = CC0F01C1D03006A1DD67A151 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + From 98f02395d5d16cc21df28939483aeae32c6dec65 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 20:28:10 +0300 Subject: [PATCH 05/59] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B5=D0=B9=20=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B9=20=D0=B2=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 803 +++++++++++------- 1 file changed, 479 insertions(+), 324 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index fd1b757..d25bf4e 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -32,6 +32,13 @@ class _ChatListScreenState extends State final Set _selectedChats = {}; final ScrollController _scrollController = ScrollController(); double _pullRatio = 0.0; + static const double _kStoriesPullTriggerPx = 16.0; + late AnimationController _storiesRevealController; + double _revealAnimBegin = 0.0; + double _closeAnimBegin = 0.0; + bool _storiesAnimClosing = false; + bool _storiesDockedOpen = false; + double _storiesCloseIntentAccum = 0.0; ProfileData? _profile; List _chats = []; SessionState _sessionState = SessionState.disconnected; @@ -80,6 +87,14 @@ class _ChatListScreenState extends State duration: const Duration(milliseconds: 1500), )..repeat(); + _storiesRevealController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ) + ..addListener(_onStoriesRevealTick) + ..addStatusListener(_onStoriesRevealStatus); + _scrollController.addListener(_onScroll); _sessionState = api.state; @@ -181,6 +196,110 @@ class _ChatListScreenState extends State ); } + void _onStoriesRevealTick() { + if (!mounted) return; + final t = Curves.easeOutCubic.transform(_storiesRevealController.value); + setState(() { + if (_storiesAnimClosing) { + _pullRatio = _closeAnimBegin * (1.0 - t); + } else { + _pullRatio = _revealAnimBegin + (1.0 - _revealAnimBegin) * t; + } + }); + } + + void _onStoriesRevealStatus(AnimationStatus status) { + if (!mounted) return; + if (status == AnimationStatus.completed) { + setState(() { + if (_storiesAnimClosing) { + _pullRatio = 0.0; + _storiesDockedOpen = false; + _storiesAnimClosing = false; + } else { + _pullRatio = 1.0; + _storiesDockedOpen = true; + } + }); + } + } + + void _startStoriesAutoReveal(double suggestedFrom) { + if (_storiesRevealController.isAnimating && !_storiesAnimClosing) return; + if (_storiesDockedOpen) return; + _storiesRevealController.stop(); + _storiesAnimClosing = false; + final from = max(_pullRatio, suggestedFrom.clamp(0.0, 1.0)); + if (from >= 1.0) { + setState(() { + _pullRatio = 1.0; + _storiesDockedOpen = true; + }); + return; + } + _revealAnimBegin = from; + _storiesRevealController.duration = Duration( + milliseconds: (260 + 240 * (1.0 - from)).round(), + ); + _storiesRevealController.reset(); + _storiesRevealController.forward(from: 0); + } + + void _startStoriesAutoClose() { + if (_pullRatio <= 0 && + !_storiesDockedOpen && + !_storiesRevealController.isAnimating) { + return; + } + if (_storiesAnimClosing && _storiesRevealController.isAnimating) return; + _storiesCloseIntentAccum = 0.0; + _storiesRevealController.stop(); + _storiesAnimClosing = true; + final from = _pullRatio.clamp(0.0, 1.0); + if (from <= 0) { + setState(() { + _pullRatio = 0.0; + _storiesDockedOpen = false; + _storiesAnimClosing = false; + }); + return; + } + _closeAnimBegin = from; + _storiesRevealController.duration = Duration( + milliseconds: (260 + 240 * from).round(), + ); + _storiesRevealController.reset(); + _storiesRevealController.forward(from: 0); + } + + bool _onStoriesScrollNotification(ScrollNotification n) { + if (_currentNavIndex != 0) return false; + if (n is! ScrollUpdateNotification) return false; + if (!_scrollController.hasClients) return false; + if (!_storiesDockedOpen || _storiesRevealController.isAnimating) { + _storiesCloseIntentAccum = 0.0; + return false; + } + final m = n.metrics; + if (m.axis != Axis.vertical) return false; + if (m.pixels > m.minScrollExtent + 1.0) { + _storiesCloseIntentAccum = 0.0; + return false; + } + final d = n.scrollDelta; + if (d == null) return false; + if (d > 0) { + _storiesCloseIntentAccum = 0.0; + return false; + } + _storiesCloseIntentAccum += -d; + if (_storiesCloseIntentAccum >= _kStoriesPullTriggerPx) { + _storiesCloseIntentAccum = 0.0; + _startStoriesAutoClose(); + } + return false; + } + void _onScroll() { if (_scrollController.hasClients) { final double offset = _scrollController.offset; @@ -191,16 +310,28 @@ class _ChatListScreenState extends State } if (offset < 0) { - final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); - if (newRatio != _pullRatio) { + final dragRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); + if (_storiesRevealController.isAnimating) { + return; + } + if (!_storiesDockedOpen && offset.abs() >= _kStoriesPullTriggerPx) { + _startStoriesAutoReveal(dragRatio); + } else if (!_storiesDockedOpen) { + if (dragRatio != _pullRatio) { + setState(() { + _pullRatio = dragRatio; + }); + } + } + } else { + if (_storiesDockedOpen || _storiesRevealController.isAnimating) { + return; + } + if (_pullRatio > 0) { setState(() { - _pullRatio = newRatio; + _pullRatio = 0.0; }); } - } else if (_pullRatio > 0) { - setState(() { - _pullRatio = 0.0; - }); } } } @@ -209,6 +340,10 @@ class _ChatListScreenState extends State void dispose() { _stateSub?.cancel(); _fabController.dispose(); + _storiesRevealController + ..removeListener(_onStoriesRevealTick) + ..removeStatusListener(_onStoriesRevealStatus) + ..dispose(); _scrollController.dispose(); super.dispose(); } @@ -242,328 +377,340 @@ class _ChatListScreenState extends State if (_scrollController.hasClients && _scrollController.offset <= 0) { if (pointerSignal.scrollDelta.dy < 0) { - // Scrolled UP (pulling down) - setState(() { - _pullRatio = (_pullRatio + 0.2).clamp(0.0, 1.0); - }); + _startStoriesAutoReveal(max(_pullRatio, 0.18)); } else if (pointerSignal.scrollDelta.dy > 0 && _pullRatio > 0) { - // Scrolled DOWN (folding up) - setState(() { - _pullRatio = (_pullRatio - 0.2).clamp(0.0, 1.0); - }); + _startStoriesAutoClose(); } } } }, - child: CustomScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverToBoxAdapter( - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - height: _shouldCollapseSearch - ? 0 - : (100 + (96 * _pullRatio)), - color: Colors.transparent, - clipBehavior: Clip.hardEdge, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - transform: Matrix4.translationValues( - 0, - _shouldCollapseSearch ? -100 : 0, - 0, - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 12, - 20, - 2, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - if (_pullRatio < 0.8) - Opacity( - opacity: 1.0 - _pullRatio, - child: Container( - width: 50 * (1.0 - _pullRatio), - height: 32, - margin: const EdgeInsets.only( - right: 8, - ), - child: Stack( - children: [ - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=dasha', - 0, - ), - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=mastika', - 1, - ), - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=stas', - 2, - ), - ], - ), - ), - ), - Text( - _sessionState == SessionState.online - ? (_profile?.firstName ?? 'Чат') - : 'Подключение...', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], - ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 16, - ), - ), - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Кнопка 1', - Symbols.settings, - ), - _buildPopupMenuItem( + child: NotificationListener( + onNotification: _onStoriesScrollNotification, + child: CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverToBoxAdapter( + child: ClipRect( + clipBehavior: Clip.hardEdge, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _shouldCollapseSearch + ? const SizedBox( + width: double.infinity, + height: 0, + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 12, + 20, 2, - 'Кнопка 2', - Symbols.notifications, ), - _buildPopupMenuItem( - 3, - 'Кнопка 3', - Symbols.shield, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (_pullRatio < 0.8) + Opacity( + opacity: 1.0 - _pullRatio, + child: Container( + width: + 50 * + (1.0 - _pullRatio), + height: 32, + margin: + const EdgeInsets.only( + right: 8, + ), + child: Stack( + children: [ + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=dasha', + 0, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=mastika', + 1, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=stas', + 2, + ), + ], + ), + ), + ), + Text( + _sessionState == + SessionState.online + ? (_profile + ?.firstName ?? + 'Чат') + : 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: + FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, + ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(16), + ), + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Кнопка 1', + Symbols.settings, + ), + _buildPopupMenuItem( + 2, + 'Кнопка 2', + Symbols.notifications, + ), + _buildPopupMenuItem( + 3, + 'Кнопка 3', + Symbols.shield, + ), + _buildPopupMenuItem( + 4, + 'Кнопка 4', + Symbols.info, + ), + ], + ), + ], ), - _buildPopupMenuItem( - 4, - 'Кнопка 4', - Symbols.info, + ), + 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, + ), + _buildStoryItem( + 'Мастика', + 'https://i.pravatar.cc/150?u=mastika', + false, + ), + ], + ), ), - ], - ), - ], - ), - ), - 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, ), - _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.withOpacity( - 0.3, - ), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: cs.error.withOpacity(0.2), - ), - ), - child: Row( - children: [ - Icon( - Symbols.cloud_off, - size: 18, - color: cs.error, - ), - const SizedBox(width: 12), - const Expanded( - child: Text( - 'Ошибка соединения, сейчас вы смотрите КЕШ', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, + 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 + .withOpacity(0.3), + borderRadius: + BorderRadius.circular(12), + border: Border.all( + color: cs.error.withOpacity( + 0.2, + ), + ), + ), + child: Row( + children: [ + Icon( + Symbols.cloud_off, + size: 18, + color: cs.error, + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Ошибка соединения, сейчас вы смотрите КЕШ', + style: TextStyle( + fontSize: 12, + fontWeight: + FontWeight.w500, + ), + ), + ), + ], ), ), ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 2, - 20, - 8, - ), - child: Container( - height: 44, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(50), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - style: TextStyle( - color: cs.onSurface, - fontSize: 15, + Padding( + padding: const EdgeInsets.fromLTRB( + 20, + 2, + 20, + 8, + ), + child: Container( + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: + BorderRadius.circular(50), ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, - ), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: + EdgeInsets.zero, + ), + ), + ), + ], ), ), ), ], ), - ), - ), - ], ), ), ), - ), - SliverPadding( - padding: EdgeInsets.zero, - sliver: SliverToBoxAdapter( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - height: 48, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - ui.PointerDeviceKind.touch, - ui.PointerDeviceKind.mouse, - ui.PointerDeviceKind.trackpad, - }, + SliverPadding( + padding: EdgeInsets.zero, + sliver: SliverToBoxAdapter( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + height: 48, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith( + dragDevices: { + ui.PointerDeviceKind.touch, + ui.PointerDeviceKind.mouse, + ui.PointerDeviceKind.trackpad, + }, + ), + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, ), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, + physics: const BouncingScrollPhysics(), + children: [ + _buildFolderChip('Все чаты'), + const SizedBox(width: 8), + _buildFolderChip('Контакты'), + const SizedBox(width: 8), + _buildFolderChip('Пидоры'), + const SizedBox(width: 8), + _buildFolderChip('Каналы'), + const SizedBox(width: 8), + _buildFolderChip('Группы'), + const SizedBox(width: 8), + _buildFolderChip('Боты'), + const SizedBox(width: 8), + _buildFolderChip('Избранное'), + const SizedBox(width: 8), + _buildFolderChip('Архив'), + ], ), - physics: const BouncingScrollPhysics(), - children: [ - _buildFolderChip('Все чаты'), - const SizedBox(width: 8), - _buildFolderChip('Контакты'), - const SizedBox(width: 8), - _buildFolderChip('Пидоры'), - const SizedBox(width: 8), - _buildFolderChip('Каналы'), - const SizedBox(width: 8), - _buildFolderChip('Группы'), - const SizedBox(width: 8), - _buildFolderChip('Боты'), - const SizedBox(width: 8), - _buildFolderChip('Избранное'), - const SizedBox(width: 8), - _buildFolderChip('Архив'), - ], ), ), ), ), - ), - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, childCount: _isInitialLoading ? 10 : _chats.length), - ), - const SliverPadding( - padding: EdgeInsets.only(bottom: 100), - ), - ], - ), // CustomScrollView - ), // Listener + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && + chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, + childCount: _isInitialLoading ? 10 : _chats.length, + ), + ), + const SliverPadding( + padding: EdgeInsets.only(bottom: 100), + ), + ], + ), + ), + ), const CallsTab(), const ContactsTab(), const SettingsTab(), @@ -785,31 +932,39 @@ class _ChatListScreenState extends State final cs = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(right: 16), - child: Column( - children: [ - Container( - padding: const EdgeInsets.all(2.5), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: hasUpdate - ? Border.all(color: cs.primary, width: 2) - : Border.all(color: cs.outlineVariant), - ), - child: CircleAvatar( - radius: 26, - backgroundImage: NetworkImage(imageUrl), - ), + child: SizedBox( + width: 68, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(2.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: hasUpdate + ? Border.all(color: cs.primary, width: 2) + : Border.all(color: cs.outlineVariant), + ), + child: CircleAvatar( + radius: 26, + backgroundImage: NetworkImage(imageUrl), + ), + ), + const SizedBox(height: 6), + Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], ), - const SizedBox(height: 6), - Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ], + ), ), ); } From 4b2e5e5c47a338796adbbde58e0e8dc6975182db Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 21:14:12 +0300 Subject: [PATCH 06/59] Updated dependencies in pubspec.lock and added internet permissions in AndroidManifest.xml --- android/app/src/main/AndroidManifest.xml | 2 ++ devtools_options.yaml | 3 +++ pubspec.lock | 16 ++++++++-------- 3 files changed, 13 insertions(+), 8 deletions(-) create mode 100644 devtools_options.yaml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c8fd234..a503dd6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + Date: Fri, 3 Apr 2026 21:29:45 +0300 Subject: [PATCH 07/59] Added localization support for multiple languages, including Russian and English, and integrated language selection in the login and country selection screens. Updated pubspec.yaml and pubspec.lock to include necessary dependencies. --- l10n.yaml | 4 + .../auth/code_confirmation_screen.dart | 13 +- lib/frontend/screens/auth/login_screen.dart | 244 +++++++-------- .../screens/auth/select_country_screen.dart | 9 +- lib/l10n/app_en.arb | 37 +++ lib/l10n/app_localizations.dart | 296 ++++++++++++++++++ lib/l10n/app_localizations_en.dart | 95 ++++++ lib/l10n/app_localizations_ru.dart | 96 ++++++ lib/l10n/app_ru.arb | 37 +++ lib/l10n/terms_of_service.dart | 11 + lib/l10n/tos_en.dart | 96 ++++++ lib/l10n/tos_ru.dart | 101 ++++++ lib/main.dart | 59 +++- pubspec.lock | 13 + pubspec.yaml | 4 + test/widget_test.dart | 44 ++- 16 files changed, 996 insertions(+), 163 deletions(-) create mode 100644 l10n.yaml create mode 100644 lib/l10n/app_en.arb create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_en.dart create mode 100644 lib/l10n/app_localizations_ru.dart create mode 100644 lib/l10n/app_ru.arb create mode 100644 lib/l10n/terms_of_service.dart create mode 100644 lib/l10n/tos_en.dart create mode 100644 lib/l10n/tos_ru.dart diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..1437ccc --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 55339be..3840751 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import '../chats/chat_list_screen.dart'; @@ -109,7 +110,10 @@ class _CodeConfirmationScreenState extends State final trackId = result.challengeTrackId; if (trackId == null) { - showCustomNotification(context, 'Ошибка: отсутствуют данные для 2FA'); + showCustomNotification( + context, + AppLocalizations.of(context)!.codeError2faMissing, + ); return; } @@ -141,6 +145,7 @@ class _CodeConfirmationScreenState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; final hasError = _errorMessage != null; return Scaffold( @@ -170,7 +175,7 @@ class _CodeConfirmationScreenState extends State ), const SizedBox(height: 12), Text( - 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.', + l10n.codeConfirmationSmsSent, style: TextStyle( color: cs.outline, fontSize: 15, @@ -326,8 +331,8 @@ class _CodeConfirmationScreenState extends State ), child: Text( _timerSeconds > 0 - ? 'Отправить повторно через $_timerSeconds сек.' - : 'Отправить код по SMS', + ? l10n.codeResendInSeconds(_timerSeconds) + : l10n.codeResendSms, ), ), ), diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index a72e298..6da104c 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -5,6 +5,8 @@ import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/countries.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:komet/l10n/terms_of_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; @@ -77,8 +79,82 @@ class _LoginScreenState extends State { } } + String _countryDisplayName(CountryName country) { + final lang = Localizations.localeOf(context).languageCode; + return lang == 'ru' ? country.ru : country.en; + } + + void _showLanguagePicker() { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final appContext = context; + showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 24.0, + horizontal: 16.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8), + child: Text( + l10n.loginLanguage, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ListTile( + title: Text( + l10n.languageNameRu, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + KometApp.stateOf(appContext)?.applyLocale(const Locale('ru')); + }, + ), + ListTile( + title: Text( + l10n.languageNameEn, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + KometApp.stateOf(appContext)?.applyLocale(const Locale('en')); + }, + ), + ], + ), + ), + ); + }, + ); + } + void _showTOS(BuildContext context) { final cs = Theme.of(context).colorScheme; + final termsLocale = Localizations.localeOf(context); showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, @@ -107,7 +183,7 @@ class _LoginScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Условия использования', + AppLocalizations.of(context)!.loginTermsOfUse, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 20, @@ -147,99 +223,8 @@ class _LoginScreenState extends State { child: ListView( controller: scrollController, children: [ - //У мя вопрос а тут точно номера статей верные??? Text( - 'Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet"\n\n' - '1. Статус и отношения\n' - '1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX").\n' - '1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа».\n' - '1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям.\n\n' - '2. Условия использования\n' - '2.1. Используя Приложение «Komet», вы:\n' - '• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps)\n' - '• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»;\n' - '• Принимаете на себя все риски, связанные с использованием Приложения.\n' - '2.2. Строго запрещено:\n' - '• Использовать Приложение «Komet» для распространения запрещённого контента;\n' - '• Осуществлять массовые рассылки (спам);\n' - '• Нарушать законодательство РФ и международное право;\n' - '• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX».\n' - '2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ.\n' - '2.4. Особенности технического взаимодействия:\n' - '• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru)\n' - '• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости;\n' - '• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО.\n\n' - '3. Технические аспекты\n' - '3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки.\n' - '3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости.\n' - '3.3. Разработчики не несут ответственности за:\n' - '• Изменения в API оригинального сервиса;\n' - '• Блокировку аккаунтов пользователей;\n' - '• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа».\n\n' - '4. Конфиденциальность\n' - '4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей.\n' - '4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа».\n' - '4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей.\n\n' - '5. Ответственность и ограничения\n' - '5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности.\n' - '5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин.\n\n' - '6. Правовые основания\n' - '6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с:\n' - '• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости;\n' - '• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях;\n' - '• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации;\n' - '• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ.\n' - '6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через:\n' - '• Публичные API‑интерфейсы, доступные через веб‑версию сервиса;\n' - '• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости;\n' - '• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны.\n' - '6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ.\n\n' - '7. Заключительные положения\n' - '7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что:\n' - '• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов;\n' - '• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»;\n' - '• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки.\n' - '7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей.\n\n' - '8. Функции безопасности и конфиденциальности\n' - '8.1. Приложение «Komet» включает инструменты защиты приватности:\n' - '• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT);\n' - '• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия;\n' - '• Ограничение телеметрии — для минимизации передачи диагностических данных.\n' - '8.2. Данные функции:\n' - '• Направлены исключительно на защиту конфиденциальности пользователей;\n' - '• Не используются для обхода систем безопасности оригинального сервиса;\n' - '• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни.\n' - '8.3. Разработчики не несут ответственности за:\n' - '• Блокировки, связанные с использованием инструментов конфиденциальности;\n' - '• Изменения в работе сервиса при активации данных функций.\n' - '8.4. Функции экспорта и импорта сессии\n' - '8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для:\n' - '• Обеспечения переносимости данных между устройствами пользователя\n' - '• Резервного копирования учетных данных\n' - '• Восстановления доступа при утере устройства\n' - '8.4.2. Особенности реализации:\n' - '• Экспорт сессии осуществляется без привязки к номеру телефона\n' - '• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256\n' - '• Ключ шифрования известен только пользователю и не сохраняется в приложении\n' - '8.4.3. Техническая реализация экспорта сессии:\n' - '• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе\n' - '• Используется подмена параметров сессии для сохранения контекста аутентификации\n' - '• Интеграция настроек прокси для обеспечения единой конфигурации подключения\n' - '• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки\n' - '• Серверы оригинального сервиса не получают данных о смене устройства пользователя\n' - '• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг)\n' - '8.4.4. Правовые основания:\n' - '• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта\n' - '• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных)\n' - '• Принцип минимизации данных — сбор только необходимой для работы информации\n' - '• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается)\n' - '• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies)\n' - '• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152)\n' - '8.4.5. Ограничения ответственности:\n' - '• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий\n' - '• Разработчики не имеют доступа к зашифрованным данным сессии\n' - '• Восстановление утерянных паролей невозможно в целях безопасности\n' - '• Ключи шифрования не хранятся в приложении и известны только пользователю', + termsOfServiceBody(termsLocale), style: TextStyle( color: cs.onSurfaceVariant, fontSize: 14, @@ -328,6 +313,7 @@ class _LoginScreenState extends State { void _showPhoneConfirmationDialog(String formattedPhone) { final screenContext = context; + final l10n = AppLocalizations.of(screenContext)!; showGeneralDialog( context: screenContext, @@ -358,7 +344,7 @@ class _LoginScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Это правильный номер?', + l10n.loginConfirmPhoneTitle, style: GoogleFonts.inter( color: cs.onSurfaceVariant, fontSize: 14, @@ -383,7 +369,7 @@ class _LoginScreenState extends State { TextButton( onPressed: () => Navigator.pop(context), child: Text( - 'Изменить', + l10n.loginEdit, style: GoogleFonts.inter( color: cs.primary, fontSize: 15, @@ -403,26 +389,24 @@ class _LoginScreenState extends State { fullPhone, ); - if (mounted) { - Navigator.push( - screenContext, - MaterialPageRoute( - builder: (context) => CodeConfirmationScreen( - phoneNumber: - '${_selectedCountry.phoneCode} $formattedPhone', - token: result.token, - ), + if (!screenContext.mounted) return; + Navigator.push( + screenContext, + MaterialPageRoute( + builder: (context) => CodeConfirmationScreen( + phoneNumber: + '${_selectedCountry.phoneCode} $formattedPhone', + token: result.token, ), - ); - } + ), + ); } catch (e) { - if (mounted) { - _showPhoneError(e.toString()); - } + if (!screenContext.mounted) return; + _showPhoneError(e.toString()); } }, child: Text( - 'Готово', + l10n.loginDone, style: GoogleFonts.inter( color: cs.primary, fontSize: 15, @@ -443,7 +427,10 @@ class _LoginScreenState extends State { void _validateAndSubmit() { if (!_isTOSRead) { - showCustomNotification(context, 'Соглащение прочитай щегол'); + showCustomNotification( + context, + AppLocalizations.of(context)!.loginReadTermsNotification, + ); return; } _showPhoneConfirmationDialog(_phoneController.text); @@ -451,6 +438,7 @@ class _LoginScreenState extends State { void _showSecurityOptions(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, @@ -470,7 +458,7 @@ class _LoginScreenState extends State { ListTile( leading: Icon(Symbols.security, color: cs.onSurface), title: Text( - 'Подделка спуфа', + l10n.loginSpoofRedacted, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, @@ -490,7 +478,7 @@ class _LoginScreenState extends State { ListTile( leading: Icon(Symbols.vpn_lock, color: cs.onSurface), title: Text( - 'Прокси', + l10n.loginProxy, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, @@ -511,6 +499,7 @@ class _LoginScreenState extends State { void _showOtherLoginMethods(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; showModalBottomSheet( context: context, backgroundColor: cs.surfaceContainerHigh, @@ -530,7 +519,7 @@ class _LoginScreenState extends State { ListTile( leading: Icon(Symbols.qr_code_2, color: cs.onSurface), title: Text( - 'По QR code', + l10n.loginSignInWithQr, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, @@ -544,7 +533,7 @@ class _LoginScreenState extends State { ListTile( leading: Icon(Symbols.key, color: cs.onSurface), title: Text( - 'По токену', + l10n.loginSignInWithToken, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, @@ -558,7 +547,7 @@ class _LoginScreenState extends State { ListTile( leading: Icon(Symbols.description, color: cs.onSurface), title: Text( - 'По файлу сессии', + l10n.loginSignInWithSessionFile, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, @@ -580,6 +569,7 @@ class _LoginScreenState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: cs.surface, body: GestureDetector( @@ -617,7 +607,7 @@ class _LoginScreenState extends State { ), ), IconButton( - onPressed: () {}, + onPressed: _showLanguagePicker, icon: Icon( Symbols.language, color: cs.onSurfaceVariant, @@ -637,7 +627,7 @@ class _LoginScreenState extends State { ), const SizedBox(height: 16), Text( - 'Войдите в Komet', + l10n.loginTitle, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 32, @@ -646,7 +636,7 @@ class _LoginScreenState extends State { ), const SizedBox(height: 8), Text( - 'Проверьте код страны и введите свой\nномер телефона.', + l10n.loginSubtitle, textAlign: TextAlign.center, style: TextStyle( color: cs.onSurfaceVariant, @@ -663,11 +653,11 @@ class _LoginScreenState extends State { onTap: _showCountryPicker, borderRadius: BorderRadius.circular(50), child: _buildInputField( - label: 'Страна', + label: l10n.loginCountry, content: Row( children: [ Text( - _selectedCountry.ru, + _countryDisplayName(_selectedCountry), style: GoogleFonts.inter( color: cs.onSurface, fontSize: 15, @@ -685,7 +675,7 @@ class _LoginScreenState extends State { ), const SizedBox(height: 40), _buildInputField( - label: 'Номер телефона', + label: l10n.loginPhoneNumber, content: Row( children: [ Text( @@ -717,7 +707,7 @@ class _LoginScreenState extends State { fontWeight: FontWeight.w400, ), decoration: InputDecoration( - hintText: '(000) 000-00-00', + hintText: l10n.loginPhoneHint, hintStyle: TextStyle( color: cs.outline, fontSize: 15, @@ -768,7 +758,7 @@ class _LoginScreenState extends State { TextButton( onPressed: () => _showOtherLoginMethods(context), child: Text( - 'Другие способы входа', + l10n.loginOtherSignInMethods, style: GoogleFonts.inter( color: cs.primary, fontSize: 14, @@ -794,8 +784,7 @@ class _LoginScreenState extends State { ), children: [ TextSpan( - text: - 'Продолжая, вы соглашаетесь с \n', + text: l10n.loginTermsIntro, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 14, @@ -804,8 +793,7 @@ class _LoginScreenState extends State { ), ), TextSpan( - text: - 'пользовательскими соглашениями', + text: l10n.loginTermsLink, style: GoogleFonts.inter( color: cs.primary, fontSize: 14, diff --git a/lib/frontend/screens/auth/select_country_screen.dart b/lib/frontend/screens/auth/select_country_screen.dart index a72335e..3c5f5de 100644 --- a/lib/frontend/screens/auth/select_country_screen.dart +++ b/lib/frontend/screens/auth/select_country_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:komet/core/config/countries.dart'; +import 'package:komet/l10n/app_localizations.dart'; class SelectCountryScreen extends StatefulWidget { final CountryName selectedCountry; @@ -41,6 +42,8 @@ class _SelectCountryScreenState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final lang = Localizations.localeOf(context).languageCode; return Scaffold( backgroundColor: cs.surface, @@ -61,7 +64,7 @@ class _SelectCountryScreenState extends State { fontWeight: FontWeight.w400, ), decoration: InputDecoration( - hintText: 'Поиск страны...', + hintText: l10n.selectCountrySearchHint, hintStyle: GoogleFonts.inter( color: cs.onSurfaceVariant, fontSize: 18, @@ -72,7 +75,7 @@ class _SelectCountryScreenState extends State { onChanged: _filterCountries, ) : Text( - 'Выберите страну', + l10n.selectCountryTitle, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 20, @@ -114,7 +117,7 @@ class _SelectCountryScreenState extends State { ), ), title: Text( - country.ru, + lang == 'ru' ? country.ru : country.en, style: GoogleFonts.inter( color: cs.onSurface, fontSize: 16, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..aa424d8 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,37 @@ +{ + "@@locale": "en", + "loginTitle": "Sign in to Komet", + "loginSubtitle": "Check your country code and enter your\nphone number.", + "loginCountry": "Country", + "loginPhoneNumber": "Phone number", + "loginPhoneHint": "(000) 000-00-00", + "loginOtherSignInMethods": "Other sign-in methods", + "loginTermsIntro": "By continuing, you agree to \n", + "loginTermsLink": "the terms of use", + "loginTermsOfUse": "Terms of use", + "loginConfirmPhoneTitle": "Is this the correct number?", + "loginEdit": "Change", + "loginDone": "Done", + "loginReadTermsNotification": "Please read the terms of use first", + "loginSpoofRedacted": "Spoof redaction", + "loginProxy": "Proxy", + "loginSignInWithQr": "Sign in with QR code", + "loginSignInWithToken": "Sign in with token", + "loginSignInWithSessionFile": "Sign in with session file", + "loginLanguage": "Language", + "languageNameRu": "Русский", + "languageNameEn": "English", + "selectCountryTitle": "Select country", + "selectCountrySearchHint": "Search countries…", + "codeConfirmationSmsSent": "We sent an SMS with a verification code to your phone number.", + "codeResendInSeconds": "Resend in {seconds} s.", + "@codeResendInSeconds": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "codeResendSms": "Resend code via SMS", + "codeError2faMissing": "Error: missing data for 2FA" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..b0614d5 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,296 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_ru.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('ru'), + ]; + + /// No description provided for @loginTitle. + /// + /// In en, this message translates to: + /// **'Sign in to Komet'** + String get loginTitle; + + /// No description provided for @loginSubtitle. + /// + /// In en, this message translates to: + /// **'Check your country code and enter your\nphone number.'** + String get loginSubtitle; + + /// No description provided for @loginCountry. + /// + /// In en, this message translates to: + /// **'Country'** + String get loginCountry; + + /// No description provided for @loginPhoneNumber. + /// + /// In en, this message translates to: + /// **'Phone number'** + String get loginPhoneNumber; + + /// No description provided for @loginPhoneHint. + /// + /// In en, this message translates to: + /// **'(000) 000-00-00'** + String get loginPhoneHint; + + /// No description provided for @loginOtherSignInMethods. + /// + /// In en, this message translates to: + /// **'Other sign-in methods'** + String get loginOtherSignInMethods; + + /// No description provided for @loginTermsIntro. + /// + /// In en, this message translates to: + /// **'By continuing, you agree to \n'** + String get loginTermsIntro; + + /// No description provided for @loginTermsLink. + /// + /// In en, this message translates to: + /// **'the terms of use'** + String get loginTermsLink; + + /// No description provided for @loginTermsOfUse. + /// + /// In en, this message translates to: + /// **'Terms of use'** + String get loginTermsOfUse; + + /// No description provided for @loginConfirmPhoneTitle. + /// + /// In en, this message translates to: + /// **'Is this the correct number?'** + String get loginConfirmPhoneTitle; + + /// No description provided for @loginEdit. + /// + /// In en, this message translates to: + /// **'Change'** + String get loginEdit; + + /// No description provided for @loginDone. + /// + /// In en, this message translates to: + /// **'Done'** + String get loginDone; + + /// No description provided for @loginReadTermsNotification. + /// + /// In en, this message translates to: + /// **'Please read the terms of use first'** + String get loginReadTermsNotification; + + /// No description provided for @loginSpoofRedacted. + /// + /// In en, this message translates to: + /// **'Spoof redaction'** + String get loginSpoofRedacted; + + /// No description provided for @loginProxy. + /// + /// In en, this message translates to: + /// **'Proxy'** + String get loginProxy; + + /// No description provided for @loginSignInWithQr. + /// + /// In en, this message translates to: + /// **'Sign in with QR code'** + String get loginSignInWithQr; + + /// No description provided for @loginSignInWithToken. + /// + /// In en, this message translates to: + /// **'Sign in with token'** + String get loginSignInWithToken; + + /// No description provided for @loginSignInWithSessionFile. + /// + /// In en, this message translates to: + /// **'Sign in with session file'** + String get loginSignInWithSessionFile; + + /// No description provided for @loginLanguage. + /// + /// In en, this message translates to: + /// **'Language'** + String get loginLanguage; + + /// No description provided for @languageNameRu. + /// + /// In en, this message translates to: + /// **'Русский'** + String get languageNameRu; + + /// No description provided for @languageNameEn. + /// + /// In en, this message translates to: + /// **'English'** + String get languageNameEn; + + /// No description provided for @selectCountryTitle. + /// + /// In en, this message translates to: + /// **'Select country'** + String get selectCountryTitle; + + /// No description provided for @selectCountrySearchHint. + /// + /// In en, this message translates to: + /// **'Search countries…'** + String get selectCountrySearchHint; + + /// No description provided for @codeConfirmationSmsSent. + /// + /// In en, this message translates to: + /// **'We sent an SMS with a verification code to your phone number.'** + String get codeConfirmationSmsSent; + + /// No description provided for @codeResendInSeconds. + /// + /// In en, this message translates to: + /// **'Resend in {seconds} s.'** + String codeResendInSeconds(int seconds); + + /// No description provided for @codeResendSms. + /// + /// In en, this message translates to: + /// **'Resend code via SMS'** + String get codeResendSms; + + /// No description provided for @codeError2faMissing. + /// + /// In en, this message translates to: + /// **'Error: missing data for 2FA'** + String get codeError2faMissing; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'ru'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'ru': + return AppLocalizationsRu(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..5ddbe9b --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,95 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get loginTitle => 'Sign in to Komet'; + + @override + String get loginSubtitle => + 'Check your country code and enter your\nphone number.'; + + @override + String get loginCountry => 'Country'; + + @override + String get loginPhoneNumber => 'Phone number'; + + @override + String get loginPhoneHint => '(000) 000-00-00'; + + @override + String get loginOtherSignInMethods => 'Other sign-in methods'; + + @override + String get loginTermsIntro => 'By continuing, you agree to \n'; + + @override + String get loginTermsLink => 'the terms of use'; + + @override + String get loginTermsOfUse => 'Terms of use'; + + @override + String get loginConfirmPhoneTitle => 'Is this the correct number?'; + + @override + String get loginEdit => 'Change'; + + @override + String get loginDone => 'Done'; + + @override + String get loginReadTermsNotification => 'Please read the terms of use first'; + + @override + String get loginSpoofRedacted => 'Spoof redaction'; + + @override + String get loginProxy => 'Proxy'; + + @override + String get loginSignInWithQr => 'Sign in with QR code'; + + @override + String get loginSignInWithToken => 'Sign in with token'; + + @override + String get loginSignInWithSessionFile => 'Sign in with session file'; + + @override + String get loginLanguage => 'Language'; + + @override + String get languageNameRu => 'Русский'; + + @override + String get languageNameEn => 'English'; + + @override + String get selectCountryTitle => 'Select country'; + + @override + String get selectCountrySearchHint => 'Search countries…'; + + @override + String get codeConfirmationSmsSent => + 'We sent an SMS with a verification code to your phone number.'; + + @override + String codeResendInSeconds(int seconds) { + return 'Resend in $seconds s.'; + } + + @override + String get codeResendSms => 'Resend code via SMS'; + + @override + String get codeError2faMissing => 'Error: missing data for 2FA'; +} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart new file mode 100644 index 0000000..e63e96d --- /dev/null +++ b/lib/l10n/app_localizations_ru.dart @@ -0,0 +1,96 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get loginTitle => 'Войдите в Komet'; + + @override + String get loginSubtitle => + 'Проверьте код страны и введите свой\nномер телефона.'; + + @override + String get loginCountry => 'Страна'; + + @override + String get loginPhoneNumber => 'Номер телефона'; + + @override + String get loginPhoneHint => '(000) 000-00-00'; + + @override + String get loginOtherSignInMethods => 'Другие способы входа'; + + @override + String get loginTermsIntro => 'Продолжая, вы соглашаетесь с \n'; + + @override + String get loginTermsLink => 'пользовательскими соглашениями'; + + @override + String get loginTermsOfUse => 'Условия использования'; + + @override + String get loginConfirmPhoneTitle => 'Это правильный номер?'; + + @override + String get loginEdit => 'Изменить'; + + @override + String get loginDone => 'Готово'; + + @override + String get loginReadTermsNotification => + 'Сначала прочитайте условия использования'; + + @override + String get loginSpoofRedacted => 'Подделка спуфа'; + + @override + String get loginProxy => 'Прокси'; + + @override + String get loginSignInWithQr => 'По QR code'; + + @override + String get loginSignInWithToken => 'По токену'; + + @override + String get loginSignInWithSessionFile => 'По файлу сессии'; + + @override + String get loginLanguage => 'Язык'; + + @override + String get languageNameRu => 'Русский'; + + @override + String get languageNameEn => 'English'; + + @override + String get selectCountryTitle => 'Выберите страну'; + + @override + String get selectCountrySearchHint => 'Поиск страны…'; + + @override + String get codeConfirmationSmsSent => + 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.'; + + @override + String codeResendInSeconds(int seconds) { + return 'Отправить повторно через $seconds сек.'; + } + + @override + String get codeResendSms => 'Отправить код по SMS'; + + @override + String get codeError2faMissing => 'Ошибка: отсутствуют данные для 2FA'; +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 0000000..a53ed02 --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,37 @@ +{ + "@@locale": "ru", + "loginTitle": "Войдите в Komet", + "loginSubtitle": "Проверьте код страны и введите свой\nномер телефона.", + "loginCountry": "Страна", + "loginPhoneNumber": "Номер телефона", + "loginPhoneHint": "(000) 000-00-00", + "loginOtherSignInMethods": "Другие способы входа", + "loginTermsIntro": "Продолжая, вы соглашаетесь с \n", + "loginTermsLink": "пользовательскими соглашениями", + "loginTermsOfUse": "Условия использования", + "loginConfirmPhoneTitle": "Это правильный номер?", + "loginEdit": "Изменить", + "loginDone": "Готово", + "loginReadTermsNotification": "Сначала прочитайте условия использования", + "loginSpoofRedacted": "Подделка спуфа", + "loginProxy": "Прокси", + "loginSignInWithQr": "По QR code", + "loginSignInWithToken": "По токену", + "loginSignInWithSessionFile": "По файлу сессии", + "loginLanguage": "Язык", + "languageNameRu": "Русский", + "languageNameEn": "English", + "selectCountryTitle": "Выберите страну", + "selectCountrySearchHint": "Поиск страны…", + "codeConfirmationSmsSent": "Мы отправили SMS с кодом подтверждения на ваш номер телефона.", + "codeResendInSeconds": "Отправить повторно через {seconds} сек.", + "@codeResendInSeconds": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "codeResendSms": "Отправить код по SMS", + "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA" +} diff --git a/lib/l10n/terms_of_service.dart b/lib/l10n/terms_of_service.dart new file mode 100644 index 0000000..ec8c52a --- /dev/null +++ b/lib/l10n/terms_of_service.dart @@ -0,0 +1,11 @@ +import 'package:flutter/widgets.dart'; + +import 'tos_en.dart'; +import 'tos_ru.dart'; + +String termsOfServiceBody(Locale locale) { + if (locale.languageCode == 'ru') { + return kTermsOfServiceRu; + } + return kTermsOfServiceEn; +} diff --git a/lib/l10n/tos_en.dart b/lib/l10n/tos_en.dart new file mode 100644 index 0000000..45a3f16 --- /dev/null +++ b/lib/l10n/tos_en.dart @@ -0,0 +1,96 @@ +const String kTermsOfServiceEn = r''' +Terms of use for the unofficial MAX client named "KometClient" or "Komet" + +1. Status and relationships +1.1. "Komet" (the "App") is an unofficial third-party application and is not affiliated with Communication Platform LLC (rights holder of the "MAX" service). +1.2. The App developers are not partners, employees, or affiliates of Communication Platform LLC. +1.3. All references to the "MAX" trademarks and related services belong to their respective owners. + +2. Terms of use +2.1. By using "Komet", you: +• Automatically agree to the official MAX User Agreement (https://legal.max.ru/ps) +• Understand that using an unofficial client may lead to your account being blocked by Communication Platform LLC +• Accept all risks associated with using the App +2.2. Strictly prohibited: +• Using "Komet" to distribute prohibited content +• Sending bulk messages (spam) +• Violating applicable law +• Attempting to hack or disrupt the original "MAX" service +2.3. The technical implementation follows fair-use principles and does not infringe the rights holder's exclusive rights under applicable interoperability and fair-use norms. +2.4. Technical interaction: +• "Komet" uses publicly available interaction methods with "MAX", similar to the web client (https://web.max.ru) +• Requests are made for interoperability purposes +• The developers do not circumvent technical protection measures or decompile the original software + +3. Technical aspects +3.1. "Komet" uses only publicly available methods to interact with "MAX" through official endpoints. +3.2. Requests are made under fair use for interoperability. +3.3. The developers are not liable for: +• Changes to the original service API +• Account blocks +• Functional limitations caused by Communication Platform LLC + +4. Privacy +4.1. "Komet" does not store or process users' personal data on developers' servers. +4.2. Authentication data is sent directly to Communication Platform LLC servers. +4.3. The developers do not have access to logins, passwords, chats, or other personal user data. + +5. Liability and limitations +5.1. "Komet" is provided "as is" without warranties. +5.2. The developers may discontinue support at any time without notice. + +6. Legal basis +6.1. Development and distribution of "Komet" are carried out with regard to interoperability, fair use, and publicly available information principles, including EU Directive 2019/790 on interoperability where applicable. +6.2. Interaction with "MAX" occurs only through: +• Public interfaces available via the web client +• Reverse engineering methods permitted for interoperability purposes where applicable by law +• Open interaction protocols not protected by technical protection measures +6.3. "Komet" does not circumvent technical protection measures or disrupt the normal operation of the original service. + +7. Final provisions +7.1. By using "Komet", you agree that: +• The authorized way to use "MAX" is through official clients +• Service-related claims should be directed to Communication Platform LLC +• The App developers are not liable for any direct or indirect damages +7.2. These terms may change without prior notice. + +8. Security and privacy features +8.1. "Komet" may include privacy tools: +• Session data substitution to reduce tracking via OSINT-style techniques +• Proxy connectivity for safer network access +• Reduced telemetry where applicable +8.2. These features: +• Aim to protect user privacy +• Are not intended to bypass the original service's security systems +• Are implemented with respect for privacy protections under applicable law +8.3. The developers are not liable for: +• Blocks related to privacy tools +• Service behavior when such features are enabled +8.4. Session export and import +8.4.1. "Komet" may allow exporting and importing session data for: +• Moving data between your devices +• Backing up credentials +• Restoring access if a device is lost +8.4.2. Implementation notes: +• Export may be decoupled from the phone number where technically possible +• Session data may be protected with a password and encryption (e.g. AES-256) +• Encryption keys are known only to you and are not stored by the developers +8.4.3. Technical approach: +• Export/import may use authorization tokens for service identification +• Session parameters may be adjusted to preserve authentication context +• Proxy settings may be included for a single connectivity configuration +• Imported sessions may route traffic according to your proxy settings +• The original service may not receive explicit device-change metadata beyond what the protocol normally sends +• Encryption may apply to the full exported package (session + proxy configuration) +8.4.4. Legal considerations: +• Processing is based on your consent where personal data laws require it +• Data minimization: only what is needed for operation +• Token use is not unauthorized access under applicable criminal computer-access laws +• Session handling is analogous to legitimate session persistence (e.g. cookies) +• IP masking can be a lawful way to protect personal data where applicable +8.4.5. Limitations: +• You are responsible for passwords and backups +• Developers cannot access your encrypted session exports +• Lost passwords cannot be recovered by design +• Encryption keys are not stored in the app and are known only to you +'''; diff --git a/lib/l10n/tos_ru.dart b/lib/l10n/tos_ru.dart new file mode 100644 index 0000000..7354d08 --- /dev/null +++ b/lib/l10n/tos_ru.dart @@ -0,0 +1,101 @@ +const String kTermsOfServiceRu = r''' +Условия использования неофициального клиента на MAX, именуемым "KometClient" или же "Komet" + +1. Статус и отношения +1.1. «Komet» (далее — «Приложение») — неофициальное стороннее приложение, не имеющее отношения к ООО «Коммуникационная платформа" (правообладатель сервиса «MAX"). +1.2. Разработчики Приложения не являются партнёрами, сотрудниками или аффилированными лицами ООО «Коммуникационная платформа». +1.3. Все упоминания торговых марок «MAX» и связанных сервисов принадлежат их правообладателям. + +2. Условия использования +2.1. Используя Приложение «Komet», вы: +• Автоматически подтверждаете согласие с официальным Пользовательским соглашением «MAX» (https://legal.max.ru/ps) +• Осознаёте, что использование неофициального клиента может привести к блокировке аккаунта со стороны ООО «Коммуникационная платформа»; +• Принимаете на себя все риски, связанные с использованием Приложения. +2.2. Строго запрещено: +• Использовать Приложение «Komet» для распространения запрещённого контента; +• Осуществлять массовые рассылки (спам); +• Нарушать законодательство РФ и международное право; +• Предпринимать попытки взлома или нарушения работы оригинального сервиса «MAX». +2.3. Техническая реализация соответствует принципу добросовестного использования (свободное использование) и не нарушает исключительные права правообладателя в соответствии с статьёй 1273 ГК РФ. +2.4. Особенности технического взаимодействия: +• Приложение «Komet» использует публично доступные методы взаимодействия с сервисом «MAX», аналогичные веб-версии (https://web.max.ru) +• Все запросы выполняются в рамках добросовестного использования для обеспечения совместимости; +• Разработчики не осуществляют обход технических средств защиты и не декомпилируют оригинальное ПО. + +3. Технические аспекты +3.1. Приложение «Komet» использует только публично доступные методы взаимодействия с сервисом «MAX» через официальные конечные точки. +3.2. Все запросы выполняются в рамках добросовестного использования (fair use) для обеспечения совместимости. +3.3. Разработчики не несут ответственности за: +• Изменения в API оригинального сервиса; +• Блокировку аккаунтов пользователей; +• Функциональные ограничения, вызванные действиями ООО «Коммуникационная платформа». + +4. Конфиденциальность +4.1. Приложение «Komet» не хранит и не обрабатывает персональные данные пользователей. +4.2. Все данные авторизации передаются напрямую серверам ООО «Коммуникационная платформа». +4.3. Разработчики не имеют доступа к логинам, паролям, переписке и другим персональным данным пользователей. + +5. Ответственность и ограничения +5.1. Приложение «Komet» предоставляется «как есть» (as is) без гарантий работоспособности. +5.2. Разработчики вправе прекратить поддержку Приложения в любой момент без объяснения причин. + +6. Правовые основания +6.1. Разработка и распространение Приложения «Komet» осуществляются в соответствии с: +• Статья 1280.3 ГК РФ — декомпилирование программы для обеспечения совместимости; +• Статья 1229 ГК РФ — ограничения исключительного права в информационных целях; +• Федеральный закон № 149‑ФЗ «Об информации» — использование общедоступной информации; +• Право на межоперабельность (Directive (EU) 2019/790) — обеспечение взаимодействия программ. +6.2. Взаимодействие с сервисом «MAX» осуществляется исключительно через: +• Публичные API‑интерфейсы, доступные через веб‑версию сервиса; +• Методы обратной разработки, разрешённые ст. 1280.3 ГК РФ для целей совместимости; +• Открытые протоколы взаимодействия, не защищённые техническими средствами охраны. +6.3. Приложение «Komet» не обходит технические средства защиты и не нарушает нормальную работу оригинального сервиса, что соответствует требованиям статьи 1299 ГК РФ. + +7. Заключительные положения +7.1. Используя Приложение «Komet», вы соглашаетесь с тем, что: +• Единственным правомочным способом использования сервиса «MAX» является применение официальных клиентов; +• Все претензии по работе сервиса должны направляться в ООО «Коммуникационная платформа»; +• Разработчики Приложения не несут ответственности за любые косвенные или прямые убытки. +7.2. Настоящее соглашение может быть изменено без предварительного уведомления пользователей. + +8. Функции безопасности и конфиденциальности +8.1. Приложение «Komet» включает инструменты защиты приватности: +• Подмена данных сессии — для предотвращения отслеживания пользователя с помощью продвинутых инструментов Open‑Source‑Intelligence (OSINT); +• Система прокси‑подключений — для обеспечения безопасности сетевого взаимодействия; +• Ограничение телеметрии — для минимизации передачи диагностических данных. +8.2. Данные функции: +• Направлены исключительно на защиту конфиденциальности пользователей; +• Не используются для обхода систем безопасности оригинального сервиса; +• Реализованы в рамках статьи 152.1 ГК РФ о защите частной жизни. +8.3. Разработчики не несут ответственности за: +• Блокировки, связанные с использованием инструментов конфиденциальности; +• Изменения в работе сервиса при активации данных функций. +8.4. Функции экспорта и импорта сессии +8.4.1. Приложение «Komet» предоставляет возможность экспорта и импорта данных сессии для: +• Обеспечения переносимости данных между устройствами пользователя +• Резервного копирования учетных данных +• Восстановления доступа при утере устройства +8.4.2. Особенности реализации: +• Экспорт сессии осуществляется без привязки к номеру телефона +• Данные сессии защищаются паролем и шифрованием по алгоритмам AES‑256 +• Ключ шифрования известен только пользователю и не сохраняется в приложении +8.4.3. Техническая реализация экспорта сессии: +• Экспорт сессии осуществляется через токен авторизации для идентификации в сервисе +• Используется подмена параметров сессии для сохранения контекста аутентификации +• Интеграция настроек прокси для обеспечения единой конфигурации подключения +• Импортированная сессия маскирует источник подключения через указанные прокси‑настройки +• Серверы оригинального сервиса не получают данных о смене устройства пользователя +• Шифрование применяется ко всему пакету данных (сессия + прокси‑конфиг) +8.4.4. Правовые основания: +• Статья 6 ФЗ‑152 «О персональных данных» — обработка данных с согласия субъекта +• Статья 434 ГК РФ — право на выбор формы сделки (электронная форма хранения учетных данных) +• Принцип минимизации данных — сбор только необходимой для работы информации +• Использование токена не является несанкционированным доступом (ст. 272 УК РФ не нарушается) +• Подмена сессии — легитимный метод сохранения аутентификации (аналог браузерных cookies) +• Маскировка IP‑адреса — законный способ защиты персональных данных (ст. 6 ФЗ‑152) +8.4.5. Ограничения ответственности: +• Пользователь самостоятельно несет ответственность за сохранность пароля и резервных копий +• Разработчики не имеют доступа к зашифрованным данным сессии +• Восстановление утерянных паролей невозможно в целях безопасности +• Ключи шифрования не хранятся в приложении и известны только пользователю +'''; diff --git a/lib/main.dart b/lib/main.dart index c8fb446..1e20ee2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,8 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'backend/api.dart'; import 'backend/modules/account.dart'; import 'backend/modules/messages.dart'; @@ -13,19 +15,65 @@ final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); +Future _loadInitialLocale() async { + final prefs = await SharedPreferences.getInstance(); + final code = prefs.getString('app_locale'); + if (code != null && (code == 'en' || code == 'ru')) { + return Locale(code); + } + final platform = WidgetsBinding.instance.platformDispatcher.locale; + if (platform.languageCode == 'en' || platform.languageCode == 'ru') { + return Locale(platform.languageCode); + } + return const Locale('ru'); +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); await AppDatabase.init(); await api.connect(); - runApp(const MyApp()); + final initialLocale = await _loadInitialLocale(); + runApp(KometApp(initialLocale: initialLocale)); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class KometApp extends StatefulWidget { + const KometApp({super.key, required this.initialLocale}); + final Locale initialLocale; + + static KometAppState? stateOf(BuildContext context) { + return context.findAncestorStateOfType(); + } + + @override + State createState() => KometAppState(); +} + +class KometAppState extends State { static const _fallbackSeed = Color(0xFFC1C4FF); - static ColorScheme _adjustScheme(ColorScheme base) { + late Locale _locale; + + @override + void initState() { + super.initState(); + _locale = widget.initialLocale; + } + + Future applyLocale(Locale locale) async { + if (!AppLocalizations.supportedLocales.any( + (l) => l.languageCode == locale.languageCode, + )) { + return; + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('app_locale', locale.languageCode); + if (mounted) { + setState(() => _locale = locale); + } + } + + ColorScheme _adjustScheme(ColorScheme base) { return base.copyWith( surface: Color.alphaBlend( base.primary.withValues(alpha: 0.05), @@ -58,6 +106,9 @@ class MyApp extends StatelessWidget { return MaterialApp( title: 'Komet', debugShowCheckedModeBanner: false, + locale: _locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, theme: ThemeData( useMaterial3: true, colorScheme: darkScheme, diff --git a/pubspec.lock b/pubspec.lock index 8948ca6..e8fa11e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -142,6 +142,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_secure_storage: dependency: "direct main" description: @@ -248,6 +253,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 244e464..2dd41b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,9 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter + intl: any # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -65,6 +68,7 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in diff --git a/test/widget_test.dart b/test/widget_test.dart index b921755..b382eb1 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,26 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:komet/main.dart'; +import 'package:komet/l10n/app_localizations.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + testWidgets('AppLocalizations Russian login title', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('ru'), + home: Builder( + builder: (context) { + return Scaffold( + body: Text(AppLocalizations.of(context)!.loginTitle), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Komet'), findsOneWidget); }); } From 7fefb71de62ca5189dd638dbdf4d5497d84623f1 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 21:38:59 +0300 Subject: [PATCH 08/59] Implemented country registration handling in the API, allowing for dynamic country selection in the login and country selection screens. Added parsing logic for registration countries and updated the UI to reflect allowed countries based on the API response. --- lib/backend/api.dart | 29 +++++++++++++++++++ lib/core/config/countries.dart | 9 ++++++ lib/frontend/screens/auth/login_screen.dart | 16 ++++++++-- .../screens/auth/select_country_screen.dart | 21 ++++++++++---- 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 1eeb9dd..aedf00d 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import '../core/config/config.dart'; +import '../core/config/countries.dart'; import '../core/protocol/opcode_map.dart'; import '../core/protocol/packet.dart'; import '../core/transport/connection.dart'; @@ -33,6 +34,11 @@ class Api { Map? get userAgent => _userAgent; + List? _registrationCountries; + + List get registrationCountries => + _registrationCountries ?? allCountries; + Stream get stateStream => _stateController.stream; SessionState get state => _sessionState; @@ -76,6 +82,7 @@ class Api { try { final response = await sendHandshake(); if (response.isOk) { + _registrationCountries = _parseRegistrationCountries(response.payload); _setSessionState(SessionState.online); _startPinging(); logger.i('Сессия онлайн, хэндшейк ок'); @@ -230,6 +237,28 @@ class Api { }); } + static List? _parseRegistrationCountries(dynamic payload) { + if (payload is! Map) return null; + final raw = payload['reg-country-code']; + if (raw is! List || raw.isEmpty) return null; + final codes = []; + for (final e in raw) { + if (e is String && e.isNotEmpty) codes.add(e.toUpperCase()); + } + if (codes.isEmpty) return null; + var list = countriesInServerOrder(codes); + if (list.isEmpty) return null; + + final loc = payload['location']; + if (loc is String && loc.length == 2) { + final home = countriesByCode[loc.toUpperCase()]; + if (home != null && !list.any((c) => c.code == home.code)) { + list = [home, ...list]; + } + } + return list; + } + void _scheduleReconnect() { if (_reconnectAttempts >= ServerConfig.maxReconnectAttempts) { logger.e('Лимит попыток реконнекта'); diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart index d19d055..e87262e 100644 --- a/lib/core/config/countries.dart +++ b/lib/core/config/countries.dart @@ -33,6 +33,15 @@ final Map countriesByCode = { for (final country in allCountries) country.code: country, }; +List countriesInServerOrder(Iterable codes) { + final out = []; + for (final raw in codes) { + final c = countriesByCode[raw.toUpperCase()]; + if (c != null) out.add(c); + } + return out; +} + /// Пример использования: /// ```dart /// final country = exampleCountryLookup('RU'); diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 6da104c..579f250 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -33,9 +33,19 @@ class _LoginScreenState extends State { void initState() { super.initState(); _selectedCountry = countriesByCode['RU'] ?? allCountries.first; + _clampCountryToAllowed(); _checkTOS(); } + void _clampCountryToAllowed() { + final allowed = api.registrationCountries; + if (allowed.any((c) => c.code == _selectedCountry.code)) return; + _selectedCountry = allowed.firstWhere( + (c) => c.code == 'RU', + orElse: () => allowed.first, + ); + } + @override void dispose() { _phoneErrorTimer?.cancel(); @@ -66,8 +76,10 @@ class _LoginScreenState extends State { final result = await Navigator.push( context, MaterialPageRoute( - builder: (context) => - SelectCountryScreen(selectedCountry: _selectedCountry), + builder: (context) => SelectCountryScreen( + selectedCountry: _selectedCountry, + countries: api.registrationCountries, + ), ), ); if (result != null) { diff --git a/lib/frontend/screens/auth/select_country_screen.dart b/lib/frontend/screens/auth/select_country_screen.dart index 3c5f5de..bf4bd03 100644 --- a/lib/frontend/screens/auth/select_country_screen.dart +++ b/lib/frontend/screens/auth/select_country_screen.dart @@ -6,8 +6,13 @@ import 'package:komet/l10n/app_localizations.dart'; class SelectCountryScreen extends StatefulWidget { final CountryName selectedCountry; + final List countries; - const SelectCountryScreen({super.key, required this.selectedCountry}); + SelectCountryScreen({ + super.key, + required this.selectedCountry, + List? countries, + }) : countries = countries ?? allCountries; @override State createState() => _SelectCountryScreenState(); @@ -16,7 +21,13 @@ class SelectCountryScreen extends StatefulWidget { class _SelectCountryScreenState extends State { bool _isSearching = false; final TextEditingController _searchController = TextEditingController(); - List _filteredCountries = allCountries; + late List _filteredCountries; + + @override + void initState() { + super.initState(); + _filteredCountries = widget.countries; + } @override void dispose() { @@ -27,10 +38,10 @@ class _SelectCountryScreenState extends State { void _filterCountries(String query) { setState(() { if (query.isEmpty) { - _filteredCountries = allCountries; + _filteredCountries = widget.countries; } else { final q = query.toLowerCase(); - _filteredCountries = allCountries.where((c) { + _filteredCountries = widget.countries.where((c) { return c.ru.toLowerCase().contains(q) || c.en.toLowerCase().contains(q) || c.phoneCode.contains(q); @@ -90,7 +101,7 @@ class _SelectCountryScreenState extends State { _isSearching = !_isSearching; if (!_isSearching) { _searchController.clear(); - _filteredCountries = allCountries; + _filteredCountries = widget.countries; } }); }, From d0d13052e06cd8235683a36558f67fb325958cc1 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 22:32:20 +0300 Subject: [PATCH 09/59] Refactor logging utility to support dynamic log levels and enhanced log formatting. Introduced KometLogPrinter for custom log output, including color coding and structured message formatting. Updated logger initialization to utilize new log level and filter functions based on release mode. --- lib/core/utils/logger.dart | 147 ++++++++++++++++++++++++++++++++++--- 1 file changed, 138 insertions(+), 9 deletions(-) diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 84ae57d..a57ef72 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -1,13 +1,142 @@ -import 'package:logger/logger.dart'; +import 'dart:convert'; + import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; + +Level _minimumLogLevel() { + const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: ''); + switch (raw.toLowerCase()) { + case 'trace': + return Level.trace; + case 'debug': + return Level.debug; + case 'info': + return Level.info; + case 'warning': + case 'warn': + return Level.warning; + case 'error': + return Level.error; + case 'fatal': + return Level.fatal; + case 'off': + return Level.off; + default: + break; + } + if (kReleaseMode) { + return Level.info; + } + return Level.trace; +} + +LogFilter _logFilter() { + if (kReleaseMode) { + return ProductionFilter(); + } + return DevelopmentFilter(); +} final logger = Logger( - level: kReleaseMode ? Level.info : Level.all, - printer: PrettyPrinter( - methodCount: 0, - errorMethodCount: 5, - lineLength: 80, - colors: true, - printEmojis: true, - ), + filter: _logFilter(), + level: _minimumLogLevel(), + printer: KometLogPrinter(), ); + +int _importanceSortKey(Level level) { + final v = level.value; + if (v >= 5999) { + return 0; + } + if (v >= 5000) { + return 1; + } + if (v >= 4000) { + return 2; + } + if (v >= 3000) { + return 3; + } + if (v >= 2000) { + return 4; + } + return 5; +} + +String _levelLetter(Level level) { + final v = level.value; + if (v >= 5999) { + return 'F'; + } + if (v >= 5000) { + return 'E'; + } + if (v >= 4000) { + return 'W'; + } + if (v >= 3000) { + return 'I'; + } + if (v >= 2000) { + return 'D'; + } + return 'T'; +} + +AnsiColor _levelColor(Level level) { + final v = level.value; + if (v >= 5999) { + return const AnsiColor.fg(199); + } + if (v >= 5000) { + return const AnsiColor.fg(196); + } + if (v >= 4000) { + return const AnsiColor.fg(208); + } + if (v >= 3000) { + return const AnsiColor.fg(12); + } + if (v >= 2000) { + return const AnsiColor.none(); + } + return AnsiColor.fg(AnsiColor.grey(0.5)); +} + +class KometLogPrinter extends LogPrinter { + KometLogPrinter({this.colors = true}); + + final bool colors; + + @override + List log(LogEvent event) { + final lines = []; + final t = event.time; + final timeStr = + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}.${t.millisecond.toString().padLeft(3, '0')}'; + final ord = _importanceSortKey(event.level).toString().padLeft(2, '0'); + final letter = _levelLetter(event.level); + final label = colors ? _levelColor(event.level)(letter) : letter; + final msg = _stringifyMessage(event.message); + lines.add('$ord|$timeStr $label $msg'); + if (event.error != null) { + lines.add('${' ' * 3}| ${event.error}'); + } + if (event.stackTrace != null && event.level.value >= 5000) { + final st = event.stackTrace.toString().split('\n'); + const limit = 12; + for (var i = 0; i < st.length && i < limit; i++) { + lines.add('${' ' * 3}| ${st[i]}'); + } + } + return lines; + } + + String _stringifyMessage(dynamic message) { + final finalMessage = message is Function ? message() : message; + if (finalMessage is Map || finalMessage is Iterable) { + return const JsonEncoder.withIndent(null).convert(finalMessage); + } + return finalMessage.toString(); + } +} From 84657860a717ca58e79f81cd0533dd11e1b57670 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 22:49:15 +0300 Subject: [PATCH 10/59] Refactor story closing logic in chat list screen to use a timestamp for layout settling --- .../screens/chats/chat_list_screen.dart | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index d25bf4e..d3ee919 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -38,7 +38,7 @@ class _ChatListScreenState extends State double _closeAnimBegin = 0.0; bool _storiesAnimClosing = false; bool _storiesDockedOpen = false; - double _storiesCloseIntentAccum = 0.0; + DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); ProfileData? _profile; List _chats = []; SessionState _sessionState = SessionState.disconnected; @@ -219,6 +219,9 @@ class _ChatListScreenState extends State } else { _pullRatio = 1.0; _storiesDockedOpen = true; + _storiesRevealLayoutSettleUntil = DateTime.now().add( + const Duration(milliseconds: 520), + ); } }); } @@ -235,6 +238,9 @@ class _ChatListScreenState extends State _pullRatio = 1.0; _storiesDockedOpen = true; }); + _storiesRevealLayoutSettleUntil = DateTime.now().add( + const Duration(milliseconds: 520), + ); return; } _revealAnimBegin = from; @@ -252,7 +258,6 @@ class _ChatListScreenState extends State return; } if (_storiesAnimClosing && _storiesRevealController.isAnimating) return; - _storiesCloseIntentAccum = 0.0; _storiesRevealController.stop(); _storiesAnimClosing = true; final from = _pullRatio.clamp(0.0, 1.0); @@ -277,26 +282,20 @@ class _ChatListScreenState extends State if (n is! ScrollUpdateNotification) return false; if (!_scrollController.hasClients) return false; if (!_storiesDockedOpen || _storiesRevealController.isAnimating) { - _storiesCloseIntentAccum = 0.0; + return false; + } + if (!DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + return false; + } + if (n.dragDetails == null) { return false; } final m = n.metrics; if (m.axis != Axis.vertical) return false; - if (m.pixels > m.minScrollExtent + 1.0) { - _storiesCloseIntentAccum = 0.0; - return false; - } + if (m.pixels > m.minScrollExtent + 1.0) return false; final d = n.scrollDelta; - if (d == null) return false; - if (d > 0) { - _storiesCloseIntentAccum = 0.0; - return false; - } - _storiesCloseIntentAccum += -d; - if (_storiesCloseIntentAccum >= _kStoriesPullTriggerPx) { - _storiesCloseIntentAccum = 0.0; - _startStoriesAutoClose(); - } + if (d == null || d <= 0) return false; + _startStoriesAutoClose(); return false; } @@ -324,6 +323,11 @@ class _ChatListScreenState extends State } } } else { + if (_storiesDockedOpen && + offset > 12 && + DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + _startStoriesAutoClose(); + } if (_storiesDockedOpen || _storiesRevealController.isAnimating) { return; } From e86ab1e509a827d7463759ada928671d1fe7f8c3 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 22:56:41 +0300 Subject: [PATCH 11/59] Add pinned chats header to chat list screen with dynamic story display --- .../screens/chats/chat_list_screen.dart | 608 +++++++++--------- 1 file changed, 292 insertions(+), 316 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index d3ee919..273f7b0 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -38,7 +38,8 @@ class _ChatListScreenState extends State double _closeAnimBegin = 0.0; bool _storiesAnimClosing = false; bool _storiesDockedOpen = false; - DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); + DateTime _storiesRevealLayoutSettleUntil = + DateTime.fromMillisecondsSinceEpoch(0); ProfileData? _profile; List _chats = []; SessionState _sessionState = SessionState.disconnected; @@ -363,6 +364,258 @@ class _ChatListScreenState extends State }); } + Widget _buildPinnedChatsHeader(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ColoredBox( + color: cs.surface, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRect( + clipBehavior: Clip.hardEdge, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _shouldCollapseSearch + ? const SizedBox(width: double.infinity, height: 0) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (_pullRatio < 0.8) + Opacity( + opacity: 1.0 - _pullRatio, + child: Container( + width: 50 * (1.0 - _pullRatio), + height: 32, + margin: const EdgeInsets.only(right: 8), + child: Stack( + children: [ + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=dasha', + 0, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=mastika', + 1, + ), + _buildFoldedStory( + 'https://i.pravatar.cc/150?u=stas', + 2, + ), + ], + ), + ), + ), + Text( + _sessionState == SessionState.online + ? (_profile?.firstName ?? 'Чат') + : 'Подключение...', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ), + ], + ), + PopupMenuButton( + icon: Icon( + Symbols.more_vert, + color: cs.outline, + weight: 400, + ), + offset: const Offset(0, 48), + elevation: 4, + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + itemBuilder: (context) => [ + _buildPopupMenuItem( + 1, + 'Кнопка 1', + Symbols.settings, + ), + _buildPopupMenuItem( + 2, + 'Кнопка 2', + Symbols.notifications, + ), + _buildPopupMenuItem( + 3, + 'Кнопка 3', + Symbols.shield, + ), + _buildPopupMenuItem( + 4, + 'Кнопка 4', + Symbols.info, + ), + ], + ), + ], + ), + ), + 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, + ), + _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.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: cs.error.withOpacity(0.2), + ), + ), + child: Row( + children: [ + Icon( + Symbols.cloud_off, + size: 18, + color: cs.error, + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Ошибка соединения, сейчас вы смотрите КЕШ', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + child: Container( + height: 44, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(50), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon( + Symbols.search, + color: cs.outline, + size: 20, + weight: 400, + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: 'Поиск', + hintStyle: TextStyle( + color: cs.outline, + fontSize: 15, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + height: 48, + color: cs.surface, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + ui.PointerDeviceKind.touch, + ui.PointerDeviceKind.mouse, + ui.PointerDeviceKind.trackpad, + }, + ), + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + physics: const BouncingScrollPhysics(), + children: [ + _buildFolderChip('Все чаты'), + const SizedBox(width: 8), + _buildFolderChip('Контакты'), + const SizedBox(width: 8), + _buildFolderChip('Пидоры'), + const SizedBox(width: 8), + _buildFolderChip('Каналы'), + const SizedBox(width: 8), + _buildFolderChip('Группы'), + const SizedBox(width: 8), + _buildFolderChip('Боты'), + const SizedBox(width: 8), + _buildFolderChip('Избранное'), + const SizedBox(width: 8), + _buildFolderChip('Архив'), + ], + ), + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -391,326 +644,49 @@ class _ChatListScreenState extends State }, child: NotificationListener( onNotification: _onStoriesScrollNotification, - child: CustomScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverToBoxAdapter( - child: ClipRect( - clipBehavior: Clip.hardEdge, - child: AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - alignment: Alignment.topCenter, - child: _shouldCollapseSearch - ? const SizedBox( - width: double.infinity, - height: 0, - ) - : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 12, - 20, - 2, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - if (_pullRatio < 0.8) - Opacity( - opacity: 1.0 - _pullRatio, - child: Container( - width: - 50 * - (1.0 - _pullRatio), - height: 32, - margin: - const EdgeInsets.only( - right: 8, - ), - child: Stack( - children: [ - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=dasha', - 0, - ), - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=mastika', - 1, - ), - _buildFoldedStory( - 'https://i.pravatar.cc/150?u=stas', - 2, - ), - ], - ), - ), - ), - Text( - _sessionState == - SessionState.online - ? (_profile - ?.firstName ?? - 'Чат') - : 'Подключение...', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: - FontWeight.w600, - fontFamily: 'Outfit', - ), - ), - ], - ), - PopupMenuButton( - icon: Icon( - Symbols.more_vert, - color: cs.outline, - weight: 400, - ), - offset: const Offset(0, 48), - elevation: 4, - color: cs.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(16), - ), - itemBuilder: (context) => [ - _buildPopupMenuItem( - 1, - 'Кнопка 1', - Symbols.settings, - ), - _buildPopupMenuItem( - 2, - 'Кнопка 2', - Symbols.notifications, - ), - _buildPopupMenuItem( - 3, - 'Кнопка 3', - Symbols.shield, - ), - _buildPopupMenuItem( - 4, - 'Кнопка 4', - Symbols.info, - ), - ], - ), - ], - ), - ), - 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, - ), - _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 - .withOpacity(0.3), - borderRadius: - BorderRadius.circular(12), - border: Border.all( - color: cs.error.withOpacity( - 0.2, - ), - ), - ), - child: Row( - children: [ - Icon( - Symbols.cloud_off, - size: 18, - color: cs.error, - ), - const SizedBox(width: 12), - const Expanded( - child: Text( - 'Ошибка соединения, сейчас вы смотрите КЕШ', - style: TextStyle( - fontSize: 12, - fontWeight: - FontWeight.w500, - ), - ), - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - 20, - 2, - 20, - 8, - ), - child: Container( - height: 44, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: - BorderRadius.circular(50), - ), - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Row( - children: [ - Icon( - Symbols.search, - color: cs.outline, - size: 20, - weight: 400, - ), - const SizedBox(width: 10), - Expanded( - child: TextField( - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - ), - decoration: InputDecoration( - hintText: 'Поиск', - hintStyle: TextStyle( - color: cs.outline, - fontSize: 15, - ), - border: InputBorder.none, - isDense: true, - contentPadding: - EdgeInsets.zero, - ), - ), - ), - ], - ), - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPinnedChatsHeader(context), + Expanded( + child: CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), ), - ), - ), - SliverPadding( - padding: EdgeInsets.zero, - sliver: SliverToBoxAdapter( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - height: 48, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context) - .copyWith( - dragDevices: { - ui.PointerDeviceKind.touch, - ui.PointerDeviceKind.mouse, - ui.PointerDeviceKind.trackpad, - }, - ), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, - ), - physics: const BouncingScrollPhysics(), - children: [ - _buildFolderChip('Все чаты'), - const SizedBox(width: 8), - _buildFolderChip('Контакты'), - const SizedBox(width: 8), - _buildFolderChip('Пидоры'), - const SizedBox(width: 8), - _buildFolderChip('Каналы'), - const SizedBox(width: 8), - _buildFolderChip('Группы'), - const SizedBox(width: 8), - _buildFolderChip('Боты'), - const SizedBox(width: 8), - _buildFolderChip('Избранное'), - const SizedBox(width: 8), - _buildFolderChip('Архив'), - ], + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && + chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, + childCount: _isInitialLoading + ? 10 + : _chats.length, ), ), - ), + const SliverPadding( + padding: EdgeInsets.only(bottom: 100), + ), + ], ), ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && - chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, - childCount: _isInitialLoading ? 10 : _chats.length, - ), - ), - const SliverPadding( - padding: EdgeInsets.only(bottom: 100), - ), ], ), ), From e78acfdbf2011209bc9056e0369f20a18e96baea Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 3 Apr 2026 23:24:32 +0300 Subject: [PATCH 12/59] Enhance chat list screen with navigation animation and dynamic tab selection. Introduced animation controller for smoother transitions and implemented drag handling for the bottom navigation bar. --- .../screens/chats/chat_list_screen.dart | 767 +++++++++++------- 1 file changed, 494 insertions(+), 273 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 273f7b0..78d1aaf 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -26,6 +26,12 @@ class _ChatListScreenState extends State with TickerProviderStateMixin { String _selectedCategory = 'Все чаты'; int _currentNavIndex = 0; + bool _navDragging = false; + double _navDragDx = 0; + double _navDragBaseLeft = 0; + late AnimationController _navPageAnimController; + double _navPageAnimStart = 0; + double _navPageAnimEnd = 0; bool _isFabOpen = false; bool _showCacheWarning = false; late AnimationController _fabController; @@ -83,6 +89,13 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); + _navPageAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + value: 1.0, + )..addListener(() { + if (mounted) setState(() {}); + }); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), @@ -345,6 +358,7 @@ class _ChatListScreenState extends State void dispose() { _stateSub?.cancel(); _fabController.dispose(); + _navPageAnimController.dispose(); _storiesRevealController ..removeListener(_onStoriesRevealTick) ..removeStatusListener(_onStoriesRevealStatus) @@ -353,6 +367,41 @@ class _ChatListScreenState extends State super.dispose(); } + double _effectivePageNavRowT({ + required double inactiveWidth, + required double Function(int index) bubbleLeftForIndex, + }) { + if (_navDragging) { + final left = (_navDragBaseLeft + _navDragDx) + .clamp(bubbleLeftForIndex(0), bubbleLeftForIndex(3)); + return ((left - 4) / inactiveWidth).clamp(0.0, 3.0); + } + if (_navPageAnimController.isAnimating) { + final t = + Curves.easeOutCubic.transform(_navPageAnimController.value); + return ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; + } + return _currentNavIndex.toDouble(); + } + + void _onNavTabSelected(int index) { + if (index == _currentNavIndex && !_navPageAnimController.isAnimating) { + return; + } + double fromT; + if (_navPageAnimController.isAnimating) { + final t = + Curves.easeOutCubic.transform(_navPageAnimController.value); + fromT = ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; + } else { + fromT = _currentNavIndex.toDouble(); + } + _navPageAnimStart = fromT; + _navPageAnimEnd = index.toDouble(); + setState(() => _currentNavIndex = index); + _navPageAnimController.forward(from: 0); + } + void _toggleFab() { setState(() { _isFabOpen = !_isFabOpen; @@ -616,6 +665,252 @@ class _ChatListScreenState extends State ); } + Widget _buildChatsTabBody() { + return Listener( + onPointerSignal: (pointerSignal) { + if (pointerSignal is PointerScrollEvent) { + if (_scrollController.hasClients && _scrollController.offset <= 0) { + if (pointerSignal.scrollDelta.dy < 0) { + _startStoriesAutoReveal(max(_pullRatio, 0.18)); + } else if (pointerSignal.scrollDelta.dy > 0 && _pullRatio > 0) { + _startStoriesAutoClose(); + } + } + } + }, + child: NotificationListener( + onNotification: _onStoriesScrollNotification, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPinnedChatsHeader(context), + Expanded( + child: CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, + childCount: + _isInitialLoading ? 10 : _chats.length, + ), + ), + const SliverPadding( + padding: EdgeInsets.only(bottom: 100), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDockedBottomNav(ColorScheme cs, double navInnerW) { + final totalWeight = 5.2; + final unitWidth = navInnerW / totalWeight; + final activeWidth = unitWidth * 2.2; + final inactiveWidth = unitWidth * 1.0; + + double bubbleLeftForIndex(int index) { + double lo = 0; + for (int i = 0; i < index; i++) { + lo += inactiveWidth; + } + return lo + 4; + } + + final leftOffset = bubbleLeftForIndex(_currentNavIndex); + final bubbleW = activeWidth - 8; + 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); + final i1 = rt.ceil().clamp(0, 3); + final frac = i0 == i1 ? 0.0 : (rt - i0); + double at(int sel, int tab) => + (tab == sel) ? (activeWidth - 0.5) : (inactiveWidth - 0.5); + return at(i0, tabIndex) + (at(i1, tabIndex) - at(i0, tabIndex)) * frac; + } + + int indexForBubbleLeft(double left) { + final cx = left + bubbleW / 2; + var best = 0; + var bestD = double.infinity; + for (var i = 0; i < 4; i++) { + final c = bubbleLeftForIndex(i) + bubbleW / 2; + final d = (c - cx).abs(); + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; + } + + return AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + left: 8, + right: 8, + bottom: _isSelectionMode ? -100 : 10.0, + child: RepaintBoundary( + child: Container( + height: 68, + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(34), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (_) { + if (_isSelectionMode) return; + _navPageAnimController.stop(); + _navPageAnimController.value = 1.0; + setState(() { + _navDragging = true; + _navDragDx = 0; + _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); + }); + }, + onHorizontalDragUpdate: (details) { + if (!_navDragging) return; + setState(() { + _navDragDx += details.delta.dx; + }); + }, + onHorizontalDragEnd: (_) { + if (!_navDragging) return; + final left = (_navDragBaseLeft + _navDragDx) + .clamp(minBubbleLeft, maxBubbleLeft); + final next = indexForBubbleLeft(left); + setState(() { + _currentNavIndex = next; + _navDragging = false; + _navDragDx = 0; + }); + }, + onHorizontalDragCancel: () { + if (!_navDragging) return; + setState(() { + _navDragging = false; + _navDragDx = 0; + }); + }, + child: Stack( + children: [ + AnimatedPositioned( + duration: _navDragging + ? Duration.zero + : const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + left: bubbleLeft, + top: 8, + bottom: 8, + width: bubbleW, + child: Container( + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(26), + ), + ), + ), + Row( + children: List.generate(4, (index) { + IconData icon; + String label; + switch (index) { + case 0: + icon = Symbols.chat_bubble; + label = 'Чаты'; + break; + case 1: + icon = Symbols.call; + label = 'Звонки'; + break; + case 2: + icon = Symbols.person_pin; + label = 'Контакты'; + break; + default: + icon = Symbols.settings; + label = 'Настройки'; + } + + final isSelected = _currentNavIndex == index; + final visualSel = navRowT.round().clamp(0, 3); + return AnimatedContainer( + duration: _navDragging + ? Duration.zero + : const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + width: _navDragging + ? navInterpolatedWidth(index, navRowT) + : (isSelected + ? (activeWidth - 0.5) + : (inactiveWidth - 0.5)), + child: ClipRRect( + borderRadius: BorderRadius.circular(26), + child: _buildNavItem( + index, + icon, + label, + selectedOverride: _navDragging + ? (index == visualSel) + : null, + instant: _navDragging, + ), + ), + ); + }), + ), + ], + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -623,286 +918,201 @@ class _ChatListScreenState extends State backgroundColor: cs.surface, body: SafeArea( bottom: false, - child: Stack( - children: [ - IndexedStack( - index: _currentNavIndex, + child: LayoutBuilder( + builder: (context, constraints) { + final pageW = constraints.maxWidth; + final pageH = constraints.maxHeight; + final navInnerW = pageW - 20; + final totalWeight = 5.2; + final unitWidth = navInnerW / totalWeight; + final inactiveWidth = unitWidth * 1.0; + double bubbleLeftForPageT(int index) { + double lo = 0; + for (int i = 0; i < index; i++) { + lo += inactiveWidth; + } + return lo + 4; + } + + final pageDisplayT = _effectivePageNavRowT( + inactiveWidth: inactiveWidth, + bubbleLeftForIndex: bubbleLeftForPageT, + ); + + final showChatsFab = !_isSelectionMode && + (_navDragging || _navPageAnimController.isAnimating + ? pageDisplayT < 1.0 + : _currentNavIndex == 0); + + return Stack( children: [ - Listener( - onPointerSignal: (pointerSignal) { - if (pointerSignal is PointerScrollEvent) { - if (_scrollController.hasClients && - _scrollController.offset <= 0) { - if (pointerSignal.scrollDelta.dy < 0) { - _startStoriesAutoReveal(max(_pullRatio, 0.18)); - } else if (pointerSignal.scrollDelta.dy > 0 && - _pullRatio > 0) { - _startStoriesAutoClose(); - } - } - } - }, - child: NotificationListener( - onNotification: _onStoriesScrollNotification, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildPinnedChatsHeader(context), - Expanded( - child: CustomScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && - chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, - childCount: _isInitialLoading - ? 10 - : _chats.length, + ClipRect( + child: SizedBox( + width: pageW, + height: pageH, + child: UnconstrainedBox( + constrainedAxis: Axis.vertical, + alignment: Alignment.topLeft, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: pageW * 4, + height: pageH, + child: Transform.translate( + offset: Offset(-pageDisplayT * pageW, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: _buildChatsTabBody(), ), ), - const SliverPadding( - padding: EdgeInsets.only(bottom: 100), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const CallsTab(), + ), + ), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const ContactsTab(), + ), + ), + RepaintBoundary( + child: SizedBox( + width: pageW, + height: pageH, + child: const SettingsTab(), + ), ), ], ), ), + ), + ), + ), + ), + _buildDockedBottomNav(cs, navInnerW), + ListenableBuilder( + listenable: _fabController, + builder: (context, child) { + final double val = Curves.easeOutCubic.transform( + _fabController.value, + ); + return Stack( + clipBehavior: Clip.none, + children: [ + if (_fabController.value > 0) + Positioned.fill( + child: GestureDetector( + onTap: _toggleFab, + behavior: HitTestBehavior.opaque, + child: Container( + color: Colors.black.withValues(alpha: val * 0.2), + ), + ), + ), + if (showChatsFab) ...[ + if (_fabController.value > 0) + Positioned( + right: 20, + bottom: 90 + 74, + child: RepaintBoundary( + child: Transform.scale( + scale: val, + alignment: Alignment.bottomRight, + child: Opacity( + opacity: val > 0.5 ? (val - 0.5) * 2 : 0, + child: _buildFabMenu(), + ), + ), + ), + ), + Positioned( + right: 20, + bottom: 90, + child: FloatingActionButton( + onPressed: _toggleFab, + backgroundColor: cs.primaryContainer, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Transform.rotate( + angle: val * (pi / 4), + child: Icon( + Symbols.add, + color: cs.onPrimaryContainer, + size: 28, + weight: 400, + ), + ), + ), + ), + ], + ], + ); + }, + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + top: _isSelectionMode ? 0 : -80, + left: 0, + right: 0, + child: Container( + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: cs.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: _clearSelection, + ), + const SizedBox(width: 8), + Text( + _selectedChats.length.toString(), + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + IconButton( + icon: Icon(Symbols.delete, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.archive, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.volume_off, color: cs.onSurface), + onPressed: () {}, + ), ], ), ), ), - const CallsTab(), - const ContactsTab(), - const SettingsTab(), ], - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - left: 8, - right: 8, - bottom: _isSelectionMode ? -100 : 10.0, - child: RepaintBoundary( - child: Container( - height: 68, - padding: const EdgeInsets.symmetric(horizontal: 2), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(34), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: LayoutBuilder( - builder: (context, constraints) { - double totalWidth = constraints.maxWidth; - - double totalWeight = 5.2; - double unitWidth = totalWidth / totalWeight; - double activeWidth = unitWidth * 2.2; - double inactiveWidth = unitWidth * 1.0; - - double leftOffset = 0; - for (int i = 0; i < _currentNavIndex; i++) { - leftOffset += inactiveWidth; - } - - return Stack( - children: [ - AnimatedPositioned( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - left: leftOffset + 4, - top: 8, - bottom: 8, - width: activeWidth - 8, - child: Container( - decoration: BoxDecoration( - color: cs.primary, - borderRadius: BorderRadius.circular(26), - ), - ), - ), - Row( - children: List.generate(4, (index) { - IconData icon; - String label; - switch (index) { - case 0: - icon = Symbols.chat_bubble; - label = 'Чаты'; - break; - case 1: - icon = Symbols.call; - label = 'Звонки'; - break; - case 2: - icon = Symbols.person_pin; - label = 'Контакты'; - break; - default: - icon = Symbols.settings; - label = 'Настройки'; - } - - bool isSelected = _currentNavIndex == index; - return AnimatedContainer( - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutCubic, - width: isSelected - ? (activeWidth - 0.5) - : (inactiveWidth - 0.5), - child: ClipRRect( - borderRadius: BorderRadius.circular(26), - child: _buildNavItem(index, icon, label), - ), - ); - }), - ), - ], - ); - }, - ), - ), - ), - ), - AnimatedBuilder( - animation: _fabController, - builder: (context, child) { - final double val = Curves.easeOutCubic.transform( - _fabController.value, - ); - return Stack( - clipBehavior: Clip.none, - children: [ - if (_fabController.value > 0) - Positioned.fill( - child: GestureDetector( - onTap: _toggleFab, - behavior: HitTestBehavior.opaque, - child: Container( - color: Colors.black.withValues(alpha: val * 0.2), - ), - ), - ), - if (!_isSelectionMode && _currentNavIndex == 0) ...[ - if (_fabController.value > 0) - Positioned( - right: 20, - bottom: 90 + 74, - child: RepaintBoundary( - child: Transform.scale( - scale: val, - alignment: Alignment.bottomRight, - child: Opacity( - opacity: val > 0.5 ? (val - 0.5) * 2 : 0, - child: _buildFabMenu(), - ), - ), - ), - ), - Positioned( - right: 20, - bottom: 90, - child: FloatingActionButton( - onPressed: _toggleFab, - backgroundColor: cs.primaryContainer, - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Transform.rotate( - angle: val * (pi / 4), - child: Icon( - Symbols.add, - color: cs.onPrimaryContainer, - size: 28, - weight: 400, - ), - ), - ), - ), - ], - ], - ); - }, - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - top: _isSelectionMode ? 0 : -80, - left: 0, - right: 0, - child: Container( - height: 64, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: cs.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: _clearSelection, - ), - const SizedBox(width: 8), - Text( - _selectedChats.length.toString(), - style: TextStyle( - color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - IconButton( - icon: Icon(Symbols.delete, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon(Symbols.archive, color: cs.onSurface), - onPressed: () {}, - ), - IconButton( - icon: Icon(Symbols.volume_off, color: cs.onSurface), - onPressed: () {}, - ), - ], - ), - ), - ), - ], + ); + }, ), ), ); @@ -1142,11 +1352,22 @@ class _ChatListScreenState extends State ); } - Widget _buildNavItem(int index, IconData icon, String label) { + Widget _buildNavItem( + int index, + IconData icon, + String label, { + bool? selectedOverride, + bool instant = false, + }) { final cs = Theme.of(context).colorScheme; - bool isSelected = _currentNavIndex == index; + final bool isSelected = + selectedOverride ?? (_currentNavIndex == index); + final Duration animDur = + instant ? Duration.zero : const Duration(milliseconds: 350); + final Duration opacityDur = + instant ? Duration.zero : const Duration(milliseconds: 200); return GestureDetector( - onTap: () => setState(() => _currentNavIndex = index), + onTap: () => _onNavTabSelected(index), behavior: HitTestBehavior.opaque, child: Center( child: FittedBox( @@ -1163,11 +1384,11 @@ class _ChatListScreenState extends State fill: isSelected ? 1.0 : 0.0, ), AnimatedContainer( - duration: const Duration(milliseconds: 350), + duration: animDur, curve: Curves.easeOutCubic, width: isSelected ? null : 0, child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), + duration: opacityDur, opacity: isSelected ? 1.0 : 0.0, child: Row( mainAxisSize: MainAxisSize.min, From 847b00aabe5f19e524e22aa1c6f744ea548ea9d6 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Fri, 3 Apr 2026 21:42:53 +0700 Subject: [PATCH 13/59] =?UTF-8?q?=D0=AD=D0=BA=D1=80=D0=B0=D0=BD=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20+=20danon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 47 ++ .../screens/profile/devices_screen.dart | 600 ++++++++++++++++++ .../screens/profile/settings_tab.dart | 28 +- 3 files changed, 669 insertions(+), 6 deletions(-) create mode 100644 lib/frontend/screens/profile/devices_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index a9af71a..82a6505 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -112,6 +112,35 @@ class LoginSyncParams { } } +class SessionInfo { + final int? id; + final String client; + final String location; + final bool current; + final int time; + final String info; + + const SessionInfo({ + this.id, + required this.client, + required this.location, + required this.current, + required this.time, + required this.info, + }); + + factory SessionInfo.fromMap(Map map) { + return SessionInfo( + id: map['id'], + client: map['client'] ?? '', + location: map['location'] ?? '', + current: map['current'] ?? false, + time: map['time'] ?? 0, + info: map['info'] ?? '', + ); + } +} + class LoginResult { final ProfileData profile; final String? updatedToken; @@ -212,6 +241,24 @@ class AccountModule { ); } + Future> getSessions() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.sessionsInfo, {}); + _checkPacketError(packet, 'getSessions'); + final data = packet.payload; + if (data is! Map || data['sessions'] is! List) return []; + final sessions = data['sessions'] as List; + return sessions + .map((s) => SessionInfo.fromMap(s as Map)) + .toList(); + } + + Future terminateOtherSessions() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.sessionsClose, {}); + _checkPacketError(packet, 'terminateOtherSessions'); + } + Future switchAccount(int accountId) async { final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart new file mode 100644 index 0000000..c31090b --- /dev/null +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -0,0 +1,600 @@ +import 'dart:io'; +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show accountModule; +import '../../../backend/modules/account.dart' show SessionInfo; +import '../../widgets/custom_notification.dart'; + +class DevicesScreen extends StatefulWidget { + const DevicesScreen({super.key}); + + @override + State createState() => _DevicesScreenState(); +} + +class _DevicesScreenState extends State + with SingleTickerProviderStateMixin { + bool _isLoading = true; + List _sessions = []; + final Map> _ipDetails = {}; + final Set _loadingIps = {}; + final Set _expandedSessions = {}; + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadSessions(); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + Future _loadSessions() async { + try { + final sessions = await accountModule.getSessions(); + if (mounted) { + setState(() { + _sessions = sessions; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка загрузки: $e'); + setState(() => _isLoading = false); + } + } + } + + Future _terminateOthers() async { + try { + await accountModule.terminateOtherSessions(); + if (mounted) { + showCustomNotification(context, 'Все сессии завершены'); + _loadSessions(); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка: $e'); + } + } + } + + Future _lookupIp(int id, String location) async { + if (_ipDetails.containsKey(id)) { + setState(() => _expandedSessions.add(id)); + return; + } + + final reg = RegExp(r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b'); + final match = reg.firstMatch(location); + if (match == null) return; + final ip = match.group(0)!; + + if (mounted) { + setState(() => _loadingIps.add(id)); + } + + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 5); + final request = await client.getUrl( + Uri.parse( + 'http://ip-api.com/json/$ip?fields=status,message,country,city,isp,as,mobile,proxy,timezone', + ), + ); + final response = await request.close(); + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final data = jsonDecode(body); + if (mounted) { + setState(() { + _ipDetails[id] = data; + _expandedSessions.add(id); + _loadingIps.remove(id); + }); + } + } + } catch (e) { + if (mounted) { + setState(() => _loadingIps.remove(id)); + showCustomNotification(context, 'Ошибка IP: $e'); + } + } + } + + String _formatTime(int timestamp) { + if (timestamp == 0) return ''; + final now = DateTime.now(); + final date = DateTime.fromMillisecondsSinceEpoch(timestamp); + + if (now.year == date.year && + now.month == date.month && + now.day == date.day) { + return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } + + final months = [ + 'янв.', + 'февр.', + 'мар.', + 'апр.', + 'мая', + 'июня', + 'июля', + 'авг.', + 'сент.', + 'окт.', + 'нояб.', + 'дек.', + ]; + + if (now.year == date.year) { + return '${date.day} ${months[date.month - 1]}'; + } + + return '${date.day}.${date.month.toString().padLeft(2, '0')}.${date.year}'; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + scrolledUnderElevation: 0, + leading: IconButton( + icon: const Icon(Symbols.chevron_left, size: 28), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Устройства', + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + centerTitle: true, + ), + body: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + children: [ + const SizedBox(height: 16), + _buildPromoCard(context, cs), + const SizedBox(height: 12), + _buildDevicesList(context, cs), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildPromoCard(BuildContext context, ColorScheme cs) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(24), + ), + child: Center( + child: Column( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: const Color(0xFF15151D), + shape: BoxShape.circle, + border: Border.all( + color: cs.onSurface.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Icon(Symbols.devices, color: cs.onSurface, size: 28), + ), + const SizedBox(height: 16), + Text( + 'Устройства в KOMET', + style: GoogleFonts.outfit( + fontSize: 18, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + 'Кто имеет доступ к вашему аккаунту?', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + height: 1.3, + ), + ), + ], + ), + ), + ); + } + + Widget _buildDevicesList(BuildContext context, ColorScheme cs) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(24), + ), + child: Column( + children: [ + if (_isLoading) + ...List.generate(5, (index) => _buildShimmerItem(cs)) + else + ..._sessions.map( + (session) => _buildDeviceItem( + context, + cs, + id: session.id ?? 0, + title: session.client + (session.current ? ' (текущая)' : ''), + platform: session.info, + location: session.location, + status: session.current ? 'В сети' : null, + time: session.current ? null : _formatTime(session.time), + isOnline: session.current, + ), + ), + if (!_isLoading) ...[ + const SizedBox(height: 12), + Divider( + height: 1, + color: cs.onSurface.withValues(alpha: 0.05), + indent: 20, + endIndent: 20, + ), + InkWell( + onTap: _terminateOthers, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(24), + ), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 20, + ), + child: Text( + 'Завершить все сессии, кроме текущей', + style: TextStyle( + color: cs.error.withValues(alpha: 0.8), + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildShimmerItem(ColorScheme cs) { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.2 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 140, + height: 16, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(height: 8), + Container( + width: 100, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 4), + Container( + width: 180, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ), + Container( + width: 40, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildDeviceItem( + BuildContext context, + ColorScheme cs, { + required int id, + required String title, + required String platform, + required String location, + String? status, + String? time, + bool isOnline = false, + }) { + final details = _ipDetails[id]; + final isLoading = _loadingIps.contains(id); + final isExpanded = _expandedSessions.contains(id); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.outfit( + fontSize: 16, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + platform, + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + Text( + location, + style: TextStyle( + fontSize: 13, + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (status != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: isOnline ? Colors.greenAccent : cs.outline, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + status, + style: TextStyle( + color: isOnline + ? Colors.greenAccent + : cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + else if (time != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + time, + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.5), + fontSize: 13, + ), + ), + ), + const SizedBox(height: 8), + if (!isExpanded) + InkWell( + onTap: isLoading ? null : () => _lookupIp(id, location), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(4), + child: isLoading + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + ) + : Icon( + Symbols.add_circle, + size: 20, + color: cs.onSurfaceVariant.withValues( + alpha: 0.4, + ), + ), + ), + ), + ], + ), + ], + ), + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutQuart, + child: isExpanded && details != null + ? Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.onSurface.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow( + cs, + Symbols.location_city, + '${details['city'] ?? 'Unknown'}, ${details['country'] ?? ''}', + ), + _buildDetailRow( + cs, + Symbols.dns, + details['isp'] ?? 'Unknown', + ), + _buildDetailRow( + cs, + Symbols.public, + details['as'] ?? 'Unknown', + ), + if (details['mobile'] == true) + _buildDetailRow( + cs, + Symbols.stay_current_portrait, + 'Мобильная сеть', + color: Colors.blueAccent, + ), + if (details['proxy'] == true) + _buildDetailRow( + cs, + Symbols.vpn_lock, + 'Обнаружен прокси/VPN', + color: Colors.orangeAccent, + ), + _buildDetailRow( + cs, + Symbols.schedule, + details['timezone'] ?? 'Unknown', + ), + ], + ), + Positioned( + right: 0, + bottom: 0, + child: InkWell( + onTap: () => + setState(() => _expandedSessions.remove(id)), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(4), + child: Icon( + Symbols.do_not_disturb_on, + size: 20, + color: cs.onSurfaceVariant.withValues( + alpha: 0.4, + ), + ), + ), + ), + ), + ], + ), + ) + : const SizedBox(width: double.infinity, height: 0), + ), + ], + ), + ); + } + + Widget _buildDetailRow( + ColorScheme cs, + IconData icon, + String text, { + Color? color, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon( + icon, + size: 14, + color: color ?? cs.onSurfaceVariant.withValues(alpha: 0.6), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 12, + color: color ?? cs.onSurfaceVariant.withValues(alpha: 0.8), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 05ca27c..1f6a0ba 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; +import 'devices_screen.dart'; class SettingsTab extends StatefulWidget { const SettingsTab({super.key}); @@ -69,13 +70,27 @@ class _SettingsTabState extends State { child: _buildSection( context, cs, - items: const [ - _SettingsItem( + items: [ + const _SettingsItem( icon: Symbols.notifications_active, label: 'Уведомления и звук', ), - _SettingsItem(icon: Symbols.lock, label: 'Безопасность'), - _SettingsItem(icon: Symbols.devices, label: 'Устройства'), + const _SettingsItem( + icon: Symbols.lock, + label: 'Безопасность', + ), + _SettingsItem( + icon: Symbols.devices, + label: 'Устройства', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DevicesScreen(), + ), + ); + }, + ), ], ), ), @@ -231,7 +246,7 @@ class _SettingsTabState extends State { Material( color: Colors.transparent, child: InkWell( - onTap: () {}, + onTap: item.onTap ?? () {}, borderRadius: isLast ? const BorderRadius.vertical(bottom: Radius.circular(20)) : null, @@ -284,8 +299,9 @@ class _SettingsTabState extends State { class _SettingsItem { final IconData icon; final String label; + final VoidCallback? onTap; - const _SettingsItem({required this.icon, required this.label}); + const _SettingsItem({required this.icon, required this.label, this.onTap}); } class _PhoneSpoiler extends StatefulWidget { From 47f870f82540125550ffa97aba4d292a303695c6 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 4 Apr 2026 13:31:13 +0700 Subject: [PATCH 14/59] =?UTF-8?q?=D0=BD=D0=B0=D1=85=D1=83=D0=B5=D0=B2?= =?UTF-8?q?=D1=91=D1=80=D1=82=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 12 ++ lib/backend/modules/account.dart | 73 ++++--- lib/backend/modules/chats.dart | 2 - lib/core/protocol/packet.dart | 4 + lib/core/transport/dispatcher.dart | 10 +- .../screens/chats/chat_list_screen.dart | 195 +++++++++++------- .../screens/profile/devices_screen.dart | 2 +- lib/main.dart | 34 ++- pubspec.lock | 16 +- 9 files changed, 236 insertions(+), 112 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index aedf00d..72f987c 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -30,6 +30,8 @@ class Api { SessionState _sessionState = SessionState.disconnected; final _stateController = StreamController.broadcast(); + final _sessionExpiredController = + StreamController.broadcast(); Map? _userAgent; Map? get userAgent => _userAgent; @@ -40,6 +42,8 @@ class Api { _registrationCountries ?? allCountries; Stream get stateStream => _stateController.stream; + Stream get sessionExpiredStream => + _sessionExpiredController.stream; SessionState get state => _sessionState; StreamSubscription? _dataSubscription; @@ -195,6 +199,7 @@ class Api { _dispatcher.dispose(); _connection.dispose(); _stateController.close(); + _sessionExpiredController.close(); } // Внутрянка @@ -208,6 +213,13 @@ class Api { Future _onDataReceived(Uint8List data) async { await for (final packet in _receiver.feed(data)) { + if (packet.isError && + packet.payload is Map && + packet.payload['message'] == 'FAIL_LOGIN_TOKEN') { + _sessionExpiredController.add( + SessionExpiredException(messageFromErrorPayload(packet.payload)), + ); + } _dispatcher.dispatch(packet); } } diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 82a6505..ec64490 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -17,6 +18,8 @@ enum AuthRequestType { final String value; } +enum LoginStatus { idle, loading, success, error } + class RequestCodeResult { final String token; @@ -39,10 +42,8 @@ class VerifyCodeResult { return c is Map ? c.cast() : null; } - /// trackId из passwordChallenge — передаётся в [AccountModule.checkPassword]. String? get challengeTrackId => passwordChallenge?['trackId'] as String?; - /// Подсказка к паролю из passwordChallenge. String? get challengeHint => passwordChallenge?['hint'] as String?; int? get accountId { @@ -68,8 +69,6 @@ class TwoFactorResult { const TwoFactorResult({required this.loginToken}); } -/// При отсутствии [LoginSyncParams] в [AccountModule.login] сервер вернёт -/// полный снимок данных (cold start), иначе только дельту (warm start). class LoginSyncParams { final int chatsSync; final int contactsSync; @@ -131,7 +130,9 @@ class SessionInfo { factory SessionInfo.fromMap(Map map) { return SessionInfo( - id: map['id'], + id: map['id'] is int + ? map['id'] + : (int.tryParse(map['id']?.toString() ?? '')), client: map['client'] ?? '', location: map['location'] ?? '', current: map['current'] ?? false, @@ -139,6 +140,23 @@ class SessionInfo { info: map['info'] ?? '', ); } + + int get uniqueId => Object.hash(id, client, time, info); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SessionInfo && + runtimeType == other.runtimeType && + id == other.id && + client == other.client && + location == other.location && + current == other.current && + time == other.time && + info == other.info; + + @override + int get hashCode => Object.hash(id, client, location, current, time, info); } class LoginResult { @@ -157,9 +175,12 @@ class LoginResult { class AccountModule { final Api _api; + final _loginStatusController = StreamController.broadcast(); AccountModule(this._api); + Stream get loginStatusStream => _loginStatusController.stream; + Future requestCode( String phone, { String language = 'ru', @@ -200,7 +221,6 @@ class AccountModule { if (sessionToken != null && accountId != null) { await TokenStorage.saveToken(sessionToken, accountId); await TokenStorage.setActiveAccount(accountId); - logger.i('Токен аккаунта $accountId сохранён, установлен активным'); } return result; @@ -226,19 +246,27 @@ class AccountModule { final requestPayload = _buildLoginPayload(authToken, syncParams); - final packet = await _api.sendRequest(Opcode.login, requestPayload); + _loginStatusController.add(LoginStatus.loading); + try { + final packet = await _api.sendRequest(Opcode.login, requestPayload); - _checkPacketError(packet, 'login'); + _checkPacketError(packet, 'login'); - final data = packet.payload; - if (data is! Map) { - throw Exception('login: неожиданный тип payload: ${data.runtimeType}'); + final data = packet.payload; + if (data is! Map) { + throw Exception('login: неожиданный тип payload: ${data.runtimeType}'); + } + + final result = await _processLoginResponse( + data.cast(), + resolvedAccountId, + ); + _loginStatusController.add(LoginStatus.success); + return result; + } catch (e) { + _loginStatusController.add(LoginStatus.error); + rethrow; } - - return _processLoginResponse( - data.cast(), - resolvedAccountId, - ); } Future> getSessions() async { @@ -278,13 +306,6 @@ class AccountModule { logger.i('Аккаунт $accountId удалён локально'); } - /// Проверяет 2FA-пароль (opcode 115). - /// - /// [trackId] — из [VerifyCodeResult.challengeTrackId]. - /// [accountId] — из [VerifyCodeResult.accountId]. - /// - /// При неверном пароле бросает [Exception]. - /// При успехе сохраняет токен и устанавливает аккаунт активным. Future checkPassword({ required String password, required String trackId, @@ -487,7 +508,11 @@ class AccountModule { void _checkPacketError(Packet packet, String method) { if (packet.isError) { - throw PacketError(messageFromErrorPayload(packet.payload)); + final payload = packet.payload; + if (payload is Map && payload['message'] == 'FAIL_LOGIN_TOKEN') { + throw SessionExpiredException(messageFromErrorPayload(payload)); + } + throw PacketError(messageFromErrorPayload(payload)); } } } diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 33413d3..f8c81d6 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -137,8 +137,6 @@ class ChatsModule { static Future clearCache(int accountId) => AppDatabase.clearChatsCache(accountId); - // internal - static Map> _buildContactsMap(dynamic contacts) { if (contacts is! List) return {}; final result = >{}; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 6970165..8bd0bc4 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -59,6 +59,10 @@ class PacketError implements Exception { String toString() => message; } +class SessionExpiredException extends PacketError { + const SessionExpiredException(super.message); +} + String messageFromErrorPayload(dynamic payload) { if (payload is Map) { for (final key in ['localizedMessage', 'message', 'title']) { diff --git a/lib/core/transport/dispatcher.dart b/lib/core/transport/dispatcher.dart index bb22136..a7ff78e 100644 --- a/lib/core/transport/dispatcher.dart +++ b/lib/core/transport/dispatcher.dart @@ -68,9 +68,13 @@ class PacketDispatcher { } if (packet.isError) { - completer.completeError( - PacketError(messageFromErrorPayload(packet.payload)), - ); + final message = messageFromErrorPayload(packet.payload); + if (packet.payload is Map && + packet.payload['message'] == 'FAIL_LOGIN_TOKEN') { + completer.completeError(SessionExpiredException(message)); + } else { + completer.completeError(PacketError(message)); + } } else { completer.complete(packet); } diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 78d1aaf..1bf2d97 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -15,6 +15,31 @@ import '../../../backend/modules/chats.dart'; import '../../../core/storage/app_database.dart'; import '../../../main.dart' show api; +class _StoriesScrollPhysics extends BouncingScrollPhysics { + final bool Function() blockPositive; + + const _StoriesScrollPhysics({ + required this.blockPositive, + ScrollPhysics? parent, + }) : super(parent: parent); + + @override + _StoriesScrollPhysics applyTo(ScrollPhysics? ancestor) { + return _StoriesScrollPhysics( + blockPositive: blockPositive, + parent: buildParent(ancestor), + ); + } + + @override + double applyBoundaryConditions(ScrollMetrics position, double value) { + if (blockPositive() && value > 0.0) { + return value - max(0.0, position.pixels); + } + return super.applyBoundaryConditions(position, value); + } +} + class ChatListScreen extends StatefulWidget { const ChatListScreen({super.key}); @@ -62,13 +87,7 @@ class _ChatListScreenState extends State _selectedChats.add(chatId); } - if (_isSelectionMode) { - if (_scrollController.hasClients && _scrollController.offset < 132) { - _shouldCollapseSearch = true; - } - } else { - _shouldCollapseSearch = false; - } + _shouldCollapseSearch = _isSelectionMode; }); } @@ -80,6 +99,20 @@ class _ChatListScreenState extends State } bool _isInitialLoading = true; + DateTime _storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0); + + bool _shouldBlockPositiveScroll() { + if (_pullRatio > 0 || + _storiesDockedOpen || + _storiesRevealController.isAnimating) { + return true; + } + if (DateTime.now().isBefore(_storiesLockdownUntil)) { + return true; + } + return false; + } + late AnimationController _shimmerController; @override @@ -89,13 +122,14 @@ class _ChatListScreenState extends State vsync: this, duration: const Duration(milliseconds: 350), ); - _navPageAnimController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), - value: 1.0, - )..addListener(() { - if (mounted) setState(() {}); - }); + _navPageAnimController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + value: 1.0, + )..addListener(() { + if (mounted) setState(() {}); + }); _shimmerController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), @@ -272,6 +306,9 @@ class _ChatListScreenState extends State return; } if (_storiesAnimClosing && _storiesRevealController.isAnimating) return; + _storiesLockdownUntil = DateTime.now().add( + const Duration(milliseconds: 800), + ); _storiesRevealController.stop(); _storiesAnimClosing = true; final from = _pullRatio.clamp(0.0, 1.0); @@ -293,8 +330,20 @@ class _ChatListScreenState extends State bool _onStoriesScrollNotification(ScrollNotification n) { if (_currentNavIndex != 0) return false; - if (n is! ScrollUpdateNotification) return false; if (!_scrollController.hasClients) return false; + + if (n is OverscrollNotification && n.overscroll > 0) { + if ((_storiesDockedOpen || + _storiesRevealController.isAnimating || + _pullRatio > 0) && + !_storiesAnimClosing && + DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + _startStoriesAutoClose(); + } + return false; + } + + if (n is! ScrollUpdateNotification) return false; if (!_storiesDockedOpen || _storiesRevealController.isAnimating) { return false; } @@ -372,13 +421,14 @@ class _ChatListScreenState extends State required double Function(int index) bubbleLeftForIndex, }) { if (_navDragging) { - final left = (_navDragBaseLeft + _navDragDx) - .clamp(bubbleLeftForIndex(0), bubbleLeftForIndex(3)); + final left = (_navDragBaseLeft + _navDragDx).clamp( + bubbleLeftForIndex(0), + bubbleLeftForIndex(3), + ); return ((left - 4) / inactiveWidth).clamp(0.0, 3.0); } if (_navPageAnimController.isAnimating) { - final t = - Curves.easeOutCubic.transform(_navPageAnimController.value); + final t = Curves.easeOutCubic.transform(_navPageAnimController.value); return ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; } return _currentNavIndex.toDouble(); @@ -390,8 +440,7 @@ class _ChatListScreenState extends State } double fromT; if (_navPageAnimController.isAnimating) { - final t = - Curves.easeOutCubic.transform(_navPageAnimController.value); + final t = Curves.easeOutCubic.transform(_navPageAnimController.value); fromT = ui.lerpDouble(_navPageAnimStart, _navPageAnimEnd, t)!; } else { fromT = _currentNavIndex.toDouble(); @@ -428,7 +477,7 @@ class _ChatListScreenState extends State curve: Curves.easeOutCubic, alignment: Alignment.topCenter, child: _shouldCollapseSearch - ? const SizedBox(width: double.infinity, height: 0) + ? const SizedBox(width: double.infinity, height: 52) : Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -667,8 +716,17 @@ class _ChatListScreenState extends State Widget _buildChatsTabBody() { return Listener( + onPointerDown: (_) { + _storiesLockdownUntil = DateTime.fromMillisecondsSinceEpoch(0); + }, onPointerSignal: (pointerSignal) { if (pointerSignal is PointerScrollEvent) { + if (_shouldBlockPositiveScroll() && + pointerSignal.scrollDelta.dy > 0) { + _storiesLockdownUntil = DateTime.now().add( + const Duration(milliseconds: 300), + ); + } if (_scrollController.hasClients && _scrollController.offset <= 0) { if (pointerSignal.scrollDelta.dy < 0) { _startStoriesAutoReveal(max(_pullRatio, 0.18)); @@ -687,37 +745,32 @@ class _ChatListScreenState extends State Expanded( child: CustomScrollView( controller: _scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), + physics: _StoriesScrollPhysics( + blockPositive: _shouldBlockPositiveScroll, + parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, - childCount: - _isInitialLoading ? 10 : _chats.length, - ), - ), - const SliverPadding( - padding: EdgeInsets.only(bottom: 100), + delegate: SliverChildBuilderDelegate((context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = _chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, childCount: _isInitialLoading ? 10 : _chats.length), ), + const SliverPadding(padding: EdgeInsets.only(bottom: 100)), ], ), ), @@ -747,12 +800,10 @@ class _ChatListScreenState extends State final maxBubbleLeft = bubbleLeftForIndex(3); final bubbleLeft = _navDragging - ? (_navDragBaseLeft + _navDragDx) - .clamp(minBubbleLeft, maxBubbleLeft) + ? (_navDragBaseLeft + _navDragDx).clamp(minBubbleLeft, maxBubbleLeft) : leftOffset; - final navRowT = - ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); + final navRowT = ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); double navInterpolatedWidth(int tabIndex, double rowT) { final rt = rowT.clamp(0.0, 3.0); @@ -820,8 +871,10 @@ class _ChatListScreenState extends State }, onHorizontalDragEnd: (_) { if (!_navDragging) return; - final left = (_navDragBaseLeft + _navDragDx) - .clamp(minBubbleLeft, maxBubbleLeft); + final left = (_navDragBaseLeft + _navDragDx).clamp( + minBubbleLeft, + maxBubbleLeft, + ); final next = indexForBubbleLeft(left); setState(() { _currentNavIndex = next; @@ -886,8 +939,8 @@ class _ChatListScreenState extends State width: _navDragging ? navInterpolatedWidth(index, navRowT) : (isSelected - ? (activeWidth - 0.5) - : (inactiveWidth - 0.5)), + ? (activeWidth - 0.5) + : (inactiveWidth - 0.5)), child: ClipRRect( borderRadius: BorderRadius.circular(26), child: _buildNavItem( @@ -939,7 +992,8 @@ class _ChatListScreenState extends State bubbleLeftForIndex: bubbleLeftForPageT, ); - final showChatsFab = !_isSelectionMode && + final showChatsFab = + !_isSelectionMode && (_navDragging || _navPageAnimController.isAnimating ? pageDisplayT < 1.0 : _currentNavIndex == 0); @@ -950,10 +1004,10 @@ class _ChatListScreenState extends State child: SizedBox( width: pageW, height: pageH, - child: UnconstrainedBox( - constrainedAxis: Axis.vertical, + child: OverflowBox( alignment: Alignment.topLeft, - clipBehavior: Clip.hardEdge, + maxWidth: pageW * 4, + maxHeight: pageH, child: SizedBox( width: pageW * 4, height: pageH, @@ -1013,7 +1067,9 @@ class _ChatListScreenState extends State onTap: _toggleFab, behavior: HitTestBehavior.opaque, child: Container( - color: Colors.black.withValues(alpha: val * 0.2), + color: Colors.black.withValues( + alpha: val * 0.2, + ), ), ), ), @@ -1066,7 +1122,7 @@ class _ChatListScreenState extends State left: 0, right: 0, child: Container( - height: 64, + height: 52, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: cs.surface, @@ -1360,12 +1416,13 @@ class _ChatListScreenState extends State bool instant = false, }) { final cs = Theme.of(context).colorScheme; - final bool isSelected = - selectedOverride ?? (_currentNavIndex == index); - final Duration animDur = - instant ? Duration.zero : const Duration(milliseconds: 350); - final Duration opacityDur = - instant ? Duration.zero : const Duration(milliseconds: 200); + final bool isSelected = selectedOverride ?? (_currentNavIndex == index); + final Duration animDur = instant + ? Duration.zero + : const Duration(milliseconds: 350); + final Duration opacityDur = instant + ? Duration.zero + : const Duration(milliseconds: 200); return GestureDetector( onTap: () => _onNavTabSelected(index), behavior: HitTestBehavior.opaque, diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index c31090b..c400d60 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -252,7 +252,7 @@ class _DevicesScreenState extends State (session) => _buildDeviceItem( context, cs, - id: session.id ?? 0, + id: session.uniqueId, title: session.client + (session.current ? ' (текущая)' : ''), platform: session.info, location: session.location, diff --git a/lib/main.dart b/lib/main.dart index 1e20ee2..a210687 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,8 +8,10 @@ import 'backend/modules/account.dart'; import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; +import 'core/protocol/packet.dart'; import 'frontend/screens/auth/login_screen.dart'; import 'frontend/screens/chats/chat_list_screen.dart'; +import 'frontend/widgets/custom_notification.dart'; final api = Api(); final accountModule = AccountModule(api); @@ -40,6 +42,7 @@ class KometApp extends StatefulWidget { const KometApp({super.key, required this.initialLocale}); final Locale initialLocale; + static final navigatorKey = GlobalKey(); static KometAppState? stateOf(BuildContext context) { return context.findAncestorStateOfType(); @@ -53,11 +56,35 @@ class KometAppState extends State { static const _fallbackSeed = Color(0xFFC1C4FF); late Locale _locale; + bool _isLoggingOut = false; @override void initState() { super.initState(); _locale = widget.initialLocale; + api.sessionExpiredStream.listen((SessionExpiredException e) async { + if (_isLoggingOut) return; + _isLoggingOut = true; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await accountModule.removeAccount(accountId); + } + + final navState = KometApp.navigatorKey.currentState; + if (navState != null) { + final overlayContext = navState.overlay?.context; + if (overlayContext != null) { + showCustomNotification(overlayContext, e.message); + } + + await navState.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (route) => false, + ); + } + _isLoggingOut = false; + }); } Future applyLocale(Locale locale) async { @@ -114,6 +141,7 @@ class KometAppState extends State { colorScheme: darkScheme, textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme), ), + navigatorKey: KometApp.navigatorKey, home: const _StartupScreen(), ); }, @@ -151,11 +179,7 @@ class _StartupScreenState extends State<_StartupScreen> { try { await accountModule.login(accountId: accountId); - } catch (e) { - debugPrint( - 'Background auto-login failed (safe to ignore if offline): $e', - ); - } + } catch (_) {} } void _goToLogin() { diff --git a/pubspec.lock b/pubspec.lock index e8fa11e..ca2281c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -614,10 +614,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: From 9a0100f5ed77acbf92352b3d81446e4de0545fbf Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 4 Apr 2026 18:29:22 +0700 Subject: [PATCH 15/59] =?UTF-8?q?=D0=98=D0=BD=D0=BA=20=D0=BD=D0=B0=D1=85?= =?UTF-8?q?=D1=83=D0=B9=20=D0=B8=D0=B4=D0=B8=20=D0=BB=D1=8E=D0=B1=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 326 ++++++++++-------- 1 file changed, 180 insertions(+), 146 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1bf2d97..1a17ef5 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -200,40 +200,43 @@ class _ChatListScreenState extends State return Opacity( opacity: opacity, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( children: [ Container( - width: 50, - height: 50, + width: 48, + height: 48, decoration: BoxDecoration( color: cs.surfaceContainerHighest, shape: BoxShape.circle, ), ), - const SizedBox(width: 16), + const SizedBox(width: 12), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 120, - height: 14, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(7), + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 120, + height: 14, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(7), + ), ), - ), - const SizedBox(height: 10), - Container( - width: double.infinity, - height: 10, - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(5), + Container( + width: double.infinity, + height: 12, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), ), - ), - ], + ], + ), ), ), ], @@ -1274,134 +1277,165 @@ class _ChatListScreenState extends State child: AnimatedContainer( duration: const Duration(milliseconds: 200), color: isSelected ? cs.primary.withOpacity(0.08) : Colors.transparent, - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 2, - ), - leading: Stack( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - CircleAvatar( - radius: 24, - backgroundColor: cs.surfaceContainerHighest, - backgroundImage: imageUrl.isNotEmpty - ? NetworkImage(imageUrl) - : null, - child: imageUrl.isEmpty - ? Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 20, + Stack( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: cs.surfaceContainerHighest, + backgroundImage: imageUrl.isNotEmpty + ? NetworkImage(imageUrl) + : null, + child: imageUrl.isEmpty + ? Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 20, + ), + ) + : null, + ), + if (isSelected) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + child: Icon( + Symbols.check, + color: cs.onPrimary, + size: 14, + weight: 600, + ), + ), + ) + else if (isOnline) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), ), - ) - : null, - ), - if (isSelected) - Positioned( - right: -2, - bottom: -2, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), - child: Icon( - Symbols.check, - color: cs.onPrimary, - size: 14, - weight: 600, - ), - ), - ) - else if (isOnline) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), - ), - ), - ], - ), - title: Row( - children: [ - Expanded( - child: Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ), - if (isMuted) - Icon( - Symbols.notifications_off, - color: cs.outlineVariant, - size: 14, - weight: 400, - ), - const SizedBox(width: 8), - Text(time, style: TextStyle(color: cs.outline, fontSize: 12)), - ], - ), - subtitle: Padding( - padding: const EdgeInsets.only(top: 4), - child: Row( - children: [ - Expanded( - child: Text( - message, - style: TextStyle( - color: isTyping ? cs.primary : cs.outline, - fontSize: 14, - fontWeight: isTyping ? FontWeight.w500 : FontWeight.w400, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (unreadCount > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: isMuted - ? cs.surfaceContainerHighest - : cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - unreadCount.toString(), - style: TextStyle( - color: isMuted ? cs.outline : cs.onSurface, - fontSize: 11, - fontWeight: FontWeight.w600, ), ), - ) - else if (isRead) - Icon( - Symbols.done_all, - color: cs.primary, - size: 16, - weight: 400, + ], + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isMuted) ...[ + const SizedBox(width: 4), + Icon( + Symbols.notifications_off, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + ], + const SizedBox(width: 8), + Text( + time, + style: TextStyle(color: cs.outline, fontSize: 12), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Text( + message, + style: TextStyle( + color: isTyping ? cs.primary : cs.outline, + fontSize: 14, + fontWeight: isTyping + ? FontWeight.w500 + : FontWeight.w400, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + if (unreadCount > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: isMuted + ? cs.surfaceContainerHighest + : cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + unreadCount.toString(), + style: TextStyle( + color: isMuted ? cs.outline : cs.onSurface, + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ) + else if (isRead) + Icon( + Symbols.done_all, + color: cs.primary, + size: 16, + weight: 400, + ), + ], + ), + ), + ], ), - ], - ), + ), + ), + ], ), ), ), From 23a4ec268ad38a5ee1d0247856823494854488e5 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 4 Apr 2026 16:02:36 +0300 Subject: [PATCH 16/59] feat(theme): system light/dark mode and Material You dynamic colors --- .../screens/chats/chat_list_screen.dart | 10 +-- lib/frontend/screens/chats/chat_screen.dart | 81 ++++++++++--------- .../screens/profile/devices_screen.dart | 2 +- lib/main.dart | 42 +++++++++- 4 files changed, 87 insertions(+), 48 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1a17ef5..439c3e0 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -502,14 +502,17 @@ class _ChatListScreenState extends State child: Stack( children: [ _buildFoldedStory( + cs, 'https://i.pravatar.cc/150?u=dasha', 0, ), _buildFoldedStory( + cs, 'https://i.pravatar.cc/150?u=mastika', 1, ), _buildFoldedStory( + cs, 'https://i.pravatar.cc/150?u=stas', 2, ), @@ -1316,7 +1319,6 @@ class _ChatListScreenState extends State Symbols.check, color: cs.onPrimary, size: 14, - weight: 600, ), ), ) @@ -1471,8 +1473,6 @@ class _ChatListScreenState extends State icon, color: isSelected ? cs.onPrimary : cs.onSurface, size: 20, - weight: 400, - fill: isSelected ? 1.0 : 0.0, ), AnimatedContainer( duration: animDur, @@ -1583,13 +1583,13 @@ class _ChatListScreenState extends State ); } - Widget _buildFoldedStory(String imageUrl, int index) { + Widget _buildFoldedStory(ColorScheme cs, String imageUrl, int index) { return Positioned( left: index * 12.0, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: Colors.black, width: 2), + border: Border.all(color: cs.surface, width: 2), ), child: CircleAvatar( radius: 12, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index ab8c985..e7dfa6d 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -104,18 +104,17 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; return Scaffold( - backgroundColor: Colors.black, + backgroundColor: cs.surface, appBar: AppBar( - backgroundColor: const Color(0xFF1B1B1B), + backgroundColor: cs.surfaceContainerHigh, + foregroundColor: cs.onSurface, elevation: 0, surfaceTintColor: Colors.transparent, + iconTheme: IconThemeData(color: cs.onSurface), leading: IconButton( - icon: const Icon( - Symbols.arrow_back, - color: Colors.white, - weight: 400, - ), + icon: const Icon(Symbols.arrow_back, weight: 400), onPressed: () => Navigator.pop(context), ), titleSpacing: 0, @@ -129,10 +128,13 @@ class _ChatScreenState extends State else CircleAvatar( radius: 18, - backgroundColor: Colors.blueGrey, + backgroundColor: cs.primaryContainer, child: Text( widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: const TextStyle(color: Colors.white, fontSize: 12), + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 12, + ), ), ), const SizedBox(width: 12), @@ -142,17 +144,17 @@ class _ChatScreenState extends State children: [ Text( widget.name, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600, fontFamily: 'Outfit', ), ), - const Text( + Text( 'last seen recently', style: TextStyle( - color: Colors.grey, + color: cs.onSurfaceVariant, fontSize: 12, fontWeight: FontWeight.w400, ), @@ -164,15 +166,11 @@ class _ChatScreenState extends State ), actions: [ IconButton( - icon: const Icon(Symbols.call, color: Colors.white, weight: 400), + icon: const Icon(Symbols.call, weight: 400), onPressed: () {}, ), IconButton( - icon: const Icon( - Symbols.more_vert, - color: Colors.white, - weight: 400, - ), + icon: const Icon(Symbols.more_vert, weight: 400), onPressed: () {}, ), ], @@ -196,6 +194,8 @@ class _ChatScreenState extends State return AnimatedBuilder( animation: _shimmerController, builder: (context, child) { + final cs = Theme.of(context).colorScheme; + final placeholder = cs.surfaceContainerHighest; final opacity = 0.3 + (0.4 * _shimmerController.value); return ListView.builder( padding: const EdgeInsets.all(16), @@ -217,8 +217,8 @@ class _ChatScreenState extends State Container( width: 36, height: 36, - decoration: const BoxDecoration( - color: Color(0xFF2B2B2B), + decoration: BoxDecoration( + color: placeholder, shape: BoxShape.circle, ), ), @@ -231,7 +231,7 @@ class _ChatScreenState extends State width: width1, height: 10, decoration: BoxDecoration( - color: const Color(0xFF2B2B2B), + color: placeholder, borderRadius: BorderRadius.circular(5), ), ), @@ -240,7 +240,7 @@ class _ChatScreenState extends State width: width2, height: 32, decoration: BoxDecoration( - color: const Color(0xFF2B2B2B), + color: placeholder, borderRadius: BorderRadius.circular(10), ), ), @@ -250,7 +250,7 @@ class _ChatScreenState extends State width: double.infinity, height: 120, decoration: BoxDecoration( - color: const Color(0xFF2B2B2B), + color: placeholder, borderRadius: BorderRadius.circular(12), ), ), @@ -265,7 +265,7 @@ class _ChatScreenState extends State height: 16, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration( - color: const Color(0xFF2B2B2B), + color: placeholder, borderRadius: BorderRadius.circular(8), ), ), @@ -286,6 +286,8 @@ class _ChatScreenState extends State } Widget _buildInputArea(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85); return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), @@ -300,10 +302,13 @@ class _ChatScreenState extends State maxHeight: 180, ), decoration: BoxDecoration( - color: const Color(0xFF1B1B1B).withOpacity(0.9), + color: Color.alphaBlend( + cs.surfaceContainerHighest.withValues(alpha: 0.92), + cs.surface, + ), borderRadius: BorderRadius.circular(28), border: Border.all( - color: Colors.white.withOpacity(0.1), + color: cs.outlineVariant.withValues(alpha: 0.5), width: 0.5, ), ), @@ -313,7 +318,7 @@ class _ChatScreenState extends State children: [ Icon( Symbols.face, - color: Colors.white.withOpacity(0.7), + color: mutedIcon, size: 24, weight: 400, ), @@ -321,24 +326,24 @@ class _ChatScreenState extends State Expanded( child: TextField( controller: _messageController, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: cs.onSurface, fontSize: 16, ), maxLines: null, keyboardType: TextInputType.multiline, textAlignVertical: TextAlignVertical.center, - decoration: const InputDecoration( + decoration: InputDecoration( hintText: 'Message', hintStyle: TextStyle( - color: Colors.grey, + color: cs.onSurfaceVariant, fontSize: 16, ), border: InputBorder.none, isDense: true, - contentPadding: EdgeInsets.symmetric( + contentPadding: const EdgeInsets.symmetric( vertical: 14, - ), // Выравниваем по Y + ), ), ), ), @@ -354,7 +359,7 @@ class _ChatScreenState extends State padding: const EdgeInsets.only(left: 12), child: Icon( Symbols.attachment, - color: Colors.white.withOpacity(0.7), + color: mutedIcon, size: 24, weight: 400, ), @@ -370,13 +375,13 @@ class _ChatScreenState extends State width: 54, height: 54, alignment: Alignment.center, - decoration: const BoxDecoration( - color: Color(0xFF2B2B2B), + decoration: BoxDecoration( + color: _hasText ? cs.primary : cs.surfaceContainerHighest, shape: BoxShape.circle, ), child: Icon( _hasText ? Symbols.send : Symbols.mic, - color: Colors.white, + color: _hasText ? cs.onPrimary : cs.onSurface, size: 24, weight: 400, ), diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index c400d60..40ab60d 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -201,7 +201,7 @@ class _DevicesScreenState extends State width: 56, height: 56, decoration: BoxDecoration( - color: const Color(0xFF15151D), + color: cs.surfaceContainerHighest, shape: BoxShape.circle, border: Border.all( color: cs.onSurface.withValues(alpha: 0.1), diff --git a/lib/main.dart b/lib/main.dart index a210687..e95dcfa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -100,7 +100,7 @@ class KometAppState extends State { } } - ColorScheme _adjustScheme(ColorScheme base) { + ColorScheme _adjustDarkScheme(ColorScheme base) { return base.copyWith( surface: Color.alphaBlend( base.primary.withValues(alpha: 0.05), @@ -117,29 +117,63 @@ class KometAppState extends State { ); } + ColorScheme _adjustLightScheme(ColorScheme base) { + return base.copyWith( + surface: Color.alphaBlend( + base.primary.withValues(alpha: 0.06), + const Color(0xFFF5F5FA), + ), + surfaceContainerHigh: Color.alphaBlend( + base.primary.withValues(alpha: 0.08), + const Color(0xFFEAEAF2), + ), + surfaceContainerHighest: Color.alphaBlend( + base.primary.withValues(alpha: 0.11), + const Color(0xFFDEDEE8), + ), + ); + } + @override Widget build(BuildContext context) { return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { - final baseScheme = + final lightBase = + lightDynamic ?? + ColorScheme.fromSeed( + seedColor: _fallbackSeed, + brightness: Brightness.light, + ); + final darkBase = darkDynamic ?? ColorScheme.fromSeed( seedColor: _fallbackSeed, brightness: Brightness.dark, ); - final darkScheme = _adjustScheme(baseScheme); + final lightScheme = _adjustLightScheme(lightBase); + final darkScheme = _adjustDarkScheme(darkBase); return MaterialApp( title: 'Komet', debugShowCheckedModeBanner: false, locale: _locale, + themeMode: ThemeMode.system, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, theme: ThemeData( + useMaterial3: true, + colorScheme: lightScheme, + textTheme: GoogleFonts.interTextTheme( + ThemeData(brightness: Brightness.light).textTheme, + ), + ), + darkTheme: ThemeData( useMaterial3: true, colorScheme: darkScheme, - textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme), + textTheme: GoogleFonts.interTextTheme( + ThemeData(brightness: Brightness.dark).textTheme, + ), ), navigatorKey: KometApp.navigatorKey, home: const _StartupScreen(), From 4b9d1c06a195952692c22abf7ec274edfee99f74 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 4 Apr 2026 16:12:08 +0300 Subject: [PATCH 17/59] ix(chat): dock nav above system bar without extra gap on gestures --- .../screens/chats/chat_list_screen.dart | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 439c3e0..6f8462e 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -776,7 +776,11 @@ class _ChatListScreenState extends State ); }, childCount: _isInitialLoading ? 10 : _chats.length), ), - const SliverPadding(padding: EdgeInsets.only(bottom: 100)), + SliverPadding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewPaddingOf(context).bottom + 100, + ), + ), ], ), ), @@ -786,7 +790,11 @@ class _ChatListScreenState extends State ); } - Widget _buildDockedBottomNav(ColorScheme cs, double navInnerW) { + Widget _buildDockedBottomNav( + ColorScheme cs, + double navInnerW, + double bottomInset, + ) { final totalWeight = 5.2; final unitWidth = navInnerW / totalWeight; final activeWidth = unitWidth * 2.2; @@ -841,7 +849,7 @@ class _ChatListScreenState extends State curve: Curves.easeOutCubic, left: 8, right: 8, - bottom: _isSelectionMode ? -100 : 10.0, + bottom: _isSelectionMode ? -100 : bottomInset + 10.0, child: RepaintBoundary( child: Container( height: 68, @@ -979,6 +987,7 @@ class _ChatListScreenState extends State bottom: false, child: LayoutBuilder( builder: (context, constraints) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; final pageW = constraints.maxWidth; final pageH = constraints.maxHeight; final navInnerW = pageW - 20; @@ -1057,7 +1066,7 @@ class _ChatListScreenState extends State ), ), ), - _buildDockedBottomNav(cs, navInnerW), + _buildDockedBottomNav(cs, navInnerW, bottomInset), ListenableBuilder( listenable: _fabController, builder: (context, child) { @@ -1083,7 +1092,7 @@ class _ChatListScreenState extends State if (_fabController.value > 0) Positioned( right: 20, - bottom: 90 + 74, + bottom: bottomInset + 90 + 74, child: RepaintBoundary( child: Transform.scale( scale: val, @@ -1097,7 +1106,7 @@ class _ChatListScreenState extends State ), Positioned( right: 20, - bottom: 90, + bottom: bottomInset + 90, child: FloatingActionButton( onPressed: _toggleFab, backgroundColor: cs.primaryContainer, From 19b69cffca23dfa04c471547fa1470c2424284f7 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 4 Apr 2026 16:39:08 +0300 Subject: [PATCH 18/59] fix(chats): require explicit pull to reveal stories after scrolling from top --- .../screens/chats/chat_list_screen.dart | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 6f8462e..0dabd5c 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -17,9 +17,11 @@ import '../../../main.dart' show api; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; + final bool Function() allowPullOverscrollTop; const _StoriesScrollPhysics({ required this.blockPositive, + required this.allowPullOverscrollTop, ScrollPhysics? parent, }) : super(parent: parent); @@ -27,6 +29,7 @@ class _StoriesScrollPhysics extends BouncingScrollPhysics { _StoriesScrollPhysics applyTo(ScrollPhysics? ancestor) { return _StoriesScrollPhysics( blockPositive: blockPositive, + allowPullOverscrollTop: allowPullOverscrollTop, parent: buildParent(ancestor), ); } @@ -36,6 +39,11 @@ class _StoriesScrollPhysics extends BouncingScrollPhysics { if (blockPositive() && value > 0.0) { return value - max(0.0, position.pixels); } + if (!allowPullOverscrollTop() && + value < position.minScrollExtent && + position.pixels <= position.minScrollExtent) { + return value - position.minScrollExtent; + } return super.applyBoundaryConditions(position, value); } } @@ -69,6 +77,7 @@ class _ChatListScreenState extends State double _closeAnimBegin = 0.0; bool _storiesAnimClosing = false; bool _storiesDockedOpen = false; + bool _storiesOverscrollRevealArmed = true; DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); ProfileData? _profile; @@ -113,6 +122,15 @@ class _ChatListScreenState extends State return false; } + bool _allowStoriesPullOverscrollTop() { + if (_storiesDockedOpen || + _storiesRevealController.isAnimating || + _pullRatio > 0) { + return true; + } + return _storiesOverscrollRevealArmed; + } + late AnimationController _shimmerController; @override @@ -267,6 +285,7 @@ class _ChatListScreenState extends State _pullRatio = 0.0; _storiesDockedOpen = false; _storiesAnimClosing = false; + _storiesOverscrollRevealArmed = true; } else { _pullRatio = 1.0; _storiesDockedOpen = true; @@ -320,6 +339,7 @@ class _ChatListScreenState extends State _pullRatio = 0.0; _storiesDockedOpen = false; _storiesAnimClosing = false; + _storiesOverscrollRevealArmed = true; }); return; } @@ -335,6 +355,15 @@ class _ChatListScreenState extends State if (_currentNavIndex != 0) return false; if (!_scrollController.hasClients) return false; + if (n is ScrollEndNotification) { + if (_scrollController.offset <= 0.5) { + setState(() { + _storiesOverscrollRevealArmed = true; + }); + } + return false; + } + if (n is OverscrollNotification && n.overscroll > 0) { if ((_storiesDockedOpen || _storiesRevealController.isAnimating || @@ -375,6 +404,9 @@ class _ChatListScreenState extends State } if (offset < 0) { + if (!_allowStoriesPullOverscrollTop()) { + return; + } final dragRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); if (_storiesRevealController.isAnimating) { return; @@ -397,9 +429,16 @@ class _ChatListScreenState extends State if (_storiesDockedOpen || _storiesRevealController.isAnimating) { return; } - if (_pullRatio > 0) { + final disarm = offset > 3 && _storiesOverscrollRevealArmed; + final clearPull = _pullRatio > 0; + if (disarm || clearPull) { setState(() { - _pullRatio = 0.0; + if (disarm) { + _storiesOverscrollRevealArmed = false; + } + if (clearPull) { + _pullRatio = 0.0; + } }); } } @@ -735,7 +774,9 @@ class _ChatListScreenState extends State } if (_scrollController.hasClients && _scrollController.offset <= 0) { if (pointerSignal.scrollDelta.dy < 0) { - _startStoriesAutoReveal(max(_pullRatio, 0.18)); + if (_allowStoriesPullOverscrollTop()) { + _startStoriesAutoReveal(max(_pullRatio, 0.18)); + } } else if (pointerSignal.scrollDelta.dy > 0 && _pullRatio > 0) { _startStoriesAutoClose(); } @@ -753,6 +794,7 @@ class _ChatListScreenState extends State controller: _scrollController, physics: _StoriesScrollPhysics( blockPositive: _shouldBlockPositiveScroll, + allowPullOverscrollTop: _allowStoriesPullOverscrollTop, parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ From 546ab8de1954234d5696281d4c6591cfee6708ff Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 4 Apr 2026 17:23:54 +0300 Subject: [PATCH 19/59] feat(chats): folder sync, local cache, and swipeable folder pages --- lib/backend/models/chat_folder.dart | 145 +++++ lib/backend/modules/account.dart | 14 + lib/backend/modules/folders.dart | 198 +++++++ .../screens/chats/chat_list_screen.dart | 513 +++++++++++++----- 4 files changed, 736 insertions(+), 134 deletions(-) create mode 100644 lib/backend/models/chat_folder.dart create mode 100644 lib/backend/modules/folders.dart diff --git a/lib/backend/models/chat_folder.dart b/lib/backend/models/chat_folder.dart new file mode 100644 index 0000000..0d6044a --- /dev/null +++ b/lib/backend/models/chat_folder.dart @@ -0,0 +1,145 @@ +class ChatFolder { + final String id; + final String title; + final String? emoji; + final List? include; + final List filters; + final bool hideEmpty; + final List widgets; + final List? favorites; + final Map? filterSubjects; + final List? options; + + ChatFolder({ + required this.id, + required this.title, + this.emoji, + this.include, + required this.filters, + required this.hideEmpty, + required this.widgets, + this.favorites, + this.filterSubjects, + this.options, + }); + + factory ChatFolder.fromJson(Map json) { + return ChatFolder( + id: json['id'].toString(), + title: json['title']?.toString() ?? '', + emoji: json['emoji']?.toString(), + include: (json['include'] as List?) + ?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? 0; + return 0; + }) + .toList(), + filters: + (json['filters'] as List?) + ?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? e; + return e; + }) + .toList() ?? + [], + hideEmpty: json['hideEmpty'] ?? false, + widgets: + (json['widgets'] as List?) + ?.map((w) { + if (w is Map) { + return ChatFolderWidget.fromJson(w); + } + return ChatFolderWidget.fromJson( + Map.from(w as Map), + ); + }) + .toList() ?? + [], + favorites: (json['favorites'] as List?) + ?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? 0; + return 0; + }) + .toList(), + filterSubjects: json['filterSubjects'] is Map + ? json['filterSubjects'] as Map + : (json['filterSubjects'] is Map + ? Map.from( + (json['filterSubjects'] as Map).cast(), + ) + : null), + options: (json['options'] as List?) + ?.map((e) { + if (e is int) return e; + if (e is String) return int.tryParse(e) ?? 0; + return 0; + }) + .toList(), + ); + } + + Map toJson() => { + 'id': id, + 'title': title, + if (emoji != null) 'emoji': emoji, + if (include != null) 'include': include, + 'filters': filters, + 'hideEmpty': hideEmpty, + 'widgets': widgets.map((w) => w.toJson()).toList(), + if (favorites != null) 'favorites': favorites, + if (filterSubjects != null) 'filterSubjects': filterSubjects, + if (options != null) 'options': options, + }; +} + +class ChatFolderWidget { + final int id; + final String name; + final String description; + final String? iconUrl; + final String? url; + final String? startParam; + final String? background; + final int? appId; + + ChatFolderWidget({ + required this.id, + required this.name, + required this.description, + this.iconUrl, + this.url, + this.startParam, + this.background, + this.appId, + }); + + factory ChatFolderWidget.fromJson(Map json) { + final rawId = json['id']; + return ChatFolderWidget( + id: rawId is int ? rawId : int.tryParse(rawId?.toString() ?? '') ?? 0, + name: json['name']?.toString() ?? '', + description: json['description']?.toString() ?? '', + iconUrl: json['iconUrl']?.toString(), + url: json['url']?.toString(), + startParam: json['startParam']?.toString(), + background: json['background']?.toString(), + appId: json['appId'] is int + ? json['appId'] as int + : int.tryParse(json['appId']?.toString() ?? ''), + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + if (iconUrl != null) 'iconUrl': iconUrl, + if (url != null) 'url': url, + if (startParam != null) 'startParam': startParam, + if (background != null) 'background': background, + if (appId != null) 'appId': appId, + }; +} diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index ec64490..67aeb2d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -7,6 +7,7 @@ import '../../core/storage/token_storage.dart'; import '../../core/utils/logger.dart'; import 'chats.dart'; import 'contacts.dart'; +import 'folders.dart'; enum AuthRequestType { startAuth('START_AUTH'), @@ -429,6 +430,19 @@ class AccountModule { await ContactsModule.syncFromLoginPayload(data, profile.id); await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id); + final config = data['config']; + if (config is Map) { + await FoldersModule.applyFromLoginConfig( + profile.id, + config.cast(), + ); + } + try { + await FoldersModule.syncFromServer(_api, profile.id); + } catch (e) { + logger.w('Папки чатов: $e'); + } + return LoginResult( profile: profile, updatedToken: updatedToken, diff --git a/lib/backend/modules/folders.dart b/lib/backend/modules/folders.dart new file mode 100644 index 0000000..7f66fd2 --- /dev/null +++ b/lib/backend/modules/folders.dart @@ -0,0 +1,198 @@ +import 'dart:convert'; + +import '../api.dart'; +import '../models/chat_folder.dart'; +import 'chats.dart'; +import '../../core/protocol/opcode_map.dart'; +import '../../core/protocol/packet.dart'; +import '../../core/storage/app_database.dart'; + +class FoldersModule { + static const _syncKey = 'chat_folders_snapshot'; + static const _listReadyKey = 'chat_folders_list_ready'; + + static Future markFoldersListReady(int accountId) async { + await AppDatabase.setSyncValue(accountId, _listReadyKey, '1'); + } + + static Future hasReceivedFoldersList(int accountId) async { + final ready = await AppDatabase.getSyncValue(accountId, _listReadyKey); + if (ready == '1') return true; + final snap = await AppDatabase.getSyncValue(accountId, _syncKey); + return snap != null && snap.isNotEmpty; + } + + static bool isAllChatsFolder(ChatFolder f) { + if (f.id == 'all.chat.folder') return true; + final t = f.title.trim().toLowerCase(); + return t == 'все' || + t == 'все чаты' || + t == 'all' || + t == 'all chats'; + } + + static String? preferredInitialFolderId(List folders) { + if (folders.isEmpty) return null; + for (final f in folders) { + if (isAllChatsFolder(f)) return f.id; + } + return folders.first.id; + } + + static void sortFoldersInPlace( + List folders, + List? foldersOrder, + ) { + if (foldersOrder == null || foldersOrder.isEmpty) return; + final orderedIds = foldersOrder.map((id) => id.toString()).toList(); + folders.sort((a, b) { + final aIndex = orderedIds.indexOf(a.id); + final bIndex = orderedIds.indexOf(b.id); + if (aIndex == -1 && bIndex == -1) return 0; + if (aIndex == -1) return 1; + if (bIndex == -1) return -1; + return aIndex.compareTo(bIndex); + }); + } + + static bool chatMatchesFolder(CachedChat chat, ChatFolder folder) { + if (folder.include != null && folder.include!.isNotEmpty) { + return folder.include!.contains(chat.id); + } + if (folder.filters.isEmpty) return false; + + final hasContact = folder.filters.any( + (f) => f == 9 || f == '9' || f == 'CONTACT', + ); + final hasNotContact = folder.filters.any( + (f) => f == 8 || f == '8' || f == 'NOT_CONTACT', + ); + + if (hasContact && hasNotContact) { + if (chat.type != 'DIALOG') return false; + return true; + } + + for (final filter in folder.filters) { + if (filter == 0 || filter == '0' || filter == 'UNREAD') { + if (chat.unreadCount > 0) return true; + } else if (filter == 9 || filter == '9' || filter == 'CONTACT') { + if (chat.type == 'DIALOG') return true; + } else if (filter == 8 || filter == '8' || filter == 'NOT_CONTACT') { + if (chat.type == 'CHAT' || chat.type == 'CHANNEL') return true; + } + } + return false; + } + + static Future> loadFolders(int accountId) async { + final raw = await AppDatabase.getSyncValue(accountId, _syncKey); + if (raw == null || raw.isEmpty) return []; + try { + final map = jsonDecode(raw) as Map; + final folders = (map['folders'] as List?) + ?.map((e) { + final m = e is Map + ? e + : Map.from(e as Map); + return ChatFolder.fromJson(m); + }) + .toList() ?? + []; + final order = map['foldersOrder'] as List?; + sortFoldersInPlace(folders, order); + return folders; + } catch (_) { + return []; + } + } + + static Future _persist( + int accountId, + List folders, + List? order, + ) async { + await AppDatabase.setSyncValue( + accountId, + _syncKey, + jsonEncode({ + 'folders': folders.map((f) => f.toJson()).toList(), + 'foldersOrder': order, + }), + ); + } + + static Future applyPayload( + int accountId, + Map payload, + ) async { + final foldersJson = payload['folders'] as List?; + final order = payload['foldersOrder'] as List?; + if (foldersJson == null && order == null) return; + + List folders; + if (foldersJson != null) { + folders = foldersJson + .map((json) { + try { + final m = json is Map + ? json + : Map.from(json as Map); + return ChatFolder.fromJson(m); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + } else { + folders = await loadFolders(accountId); + } + sortFoldersInPlace(folders, order); + await _persist(accountId, folders, order); + } + + static Future applyFromLoginConfig( + int accountId, + Map config, + ) async { + final chatFolders = config['chatFolders']; + if (chatFolders is! Map) return; + final foldersJson = chatFolders['FOLDERS'] as List?; + if (foldersJson == null) return; + final order = chatFolders['foldersOrder'] as List?; + final folders = foldersJson + .map((json) { + try { + final m = json is Map + ? json + : Map.from(json as Map); + return ChatFolder.fromJson(m); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + sortFoldersInPlace(folders, order); + await _persist(accountId, folders, order); + await markFoldersListReady(accountId); + } + + static Future syncFromServer(Api api, int accountId) async { + try { + final packet = await api.sendRequest(Opcode.foldersGet, { + 'folderSync': 0, + }); + if (packet.isError) { + throw PacketError(messageFromErrorPayload(packet.payload)); + } + final data = packet.payload; + if (data is Map) { + await applyPayload(accountId, data.cast()); + } + } finally { + await markFoldersListReady(accountId); + } + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 0dabd5c..321f739 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -11,9 +11,12 @@ import '../calls/calls_tab.dart'; import '../contacts/contacts_tab.dart'; import '../profile/settings_tab.dart'; import '../../../backend/api.dart'; +import '../../../backend/models/chat_folder.dart'; +import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; +import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; -import '../../../main.dart' show api; +import '../../../main.dart' show accountModule, api; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -57,7 +60,8 @@ class ChatListScreen extends StatefulWidget { class _ChatListScreenState extends State with TickerProviderStateMixin { - String _selectedCategory = 'Все чаты'; + String? _selectedFolderId; + List _folders = []; int _currentNavIndex = 0; bool _navDragging = false; double _navDragDx = 0; @@ -69,7 +73,9 @@ class _ChatListScreenState extends State bool _showCacheWarning = false; late AnimationController _fabController; final Set _selectedChats = {}; - final ScrollController _scrollController = ScrollController(); + late PageController _folderPageController; + final List _folderChatScrollControllers = []; + final List _folderChatScrollListenerFns = []; double _pullRatio = 0.0; static const double _kStoriesPullTriggerPx = 16.0; late AnimationController _storiesRevealController; @@ -84,6 +90,8 @@ class _ChatListScreenState extends State List _chats = []; SessionState _sessionState = SessionState.disconnected; StreamSubscription? _stateSub; + StreamSubscription? _loginSub; + bool? _foldersListKnown; bool _shouldCollapseSearch = false; bool get _isSelectionMode => _selectedChats.isNotEmpty; @@ -161,7 +169,8 @@ class _ChatListScreenState extends State ..addListener(_onStoriesRevealTick) ..addStatusListener(_onStoriesRevealStatus); - _scrollController.addListener(_onScroll); + _folderPageController = PageController(); + _syncFolderChatScrollControllers(); _sessionState = api.state; _stateSub = api.stateStream.listen((state) { @@ -175,32 +184,220 @@ class _ChatListScreenState extends State _showCacheWarning = false; } }); + if (state == SessionState.online) { + _reloadChatsAndFolders(); + } } }); - _loadProfile(); + _loginSub = accountModule.loginStatusStream.listen((status) { + if (status == LoginStatus.success) { + _reloadChatsAndFolders(); + } + }); + _reloadChatsAndFolders(); } - Future _loadProfile() async { + Future _reloadChatsAndFolders() async { final p = await AppDatabase.loadActiveProfile(); if (p != null) { final chats = await ChatsModule.getChats(p.id); + final folders = await FoldersModule.loadFolders(p.id); + final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); + final pageCount = folders.isEmpty ? 1 : folders.length; + _syncFolderChatScrollControllersForCount(pageCount); if (mounted) { setState(() { _profile = p; _chats = chats; + _folders = folders; + _foldersListKnown = foldersKnown; + if (_selectedFolderId != null && + !_folders.any((f) => f.id == _selectedFolderId)) { + _selectedFolderId = null; + } + if (_folders.isNotEmpty) { + final preferred = FoldersModule.preferredInitialFolderId(_folders); + if (_selectedFolderId == null || + !_folders.any((f) => f.id == _selectedFolderId)) { + _selectedFolderId = preferred; + } + } else { + _selectedFolderId = null; + } _isInitialLoading = false; }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _jumpFolderPageToSelection(); + }); } } else { + _syncFolderChatScrollControllersForCount(1); if (mounted) { setState(() { + _folders = []; + _selectedFolderId = null; + _foldersListKnown = null; _isInitialLoading = false; }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _jumpFolderPageToSelection(); + }); } } } + bool get _showFoldersShimmer { + if (_profile == null) return false; + if (_foldersListKnown != false) return false; + return _sessionState != SessionState.disconnected; + } + + int get _folderPageCount => _folders.isEmpty ? 1 : _folders.length; + + int get _selectedFolderIndex { + if (_folders.isEmpty) return 0; + final i = _folders.indexWhere((f) => f.id == _selectedFolderId); + if (i >= 0) return i; + return 0; + } + + int _folderIndexForId(String? id) { + if (_folders.isEmpty) return 0; + if (id == null) return 0; + final i = _folders.indexWhere((f) => f.id == id); + if (i >= 0) return i; + final pref = FoldersModule.preferredInitialFolderId(_folders); + if (pref != null) { + final j = _folders.indexWhere((f) => f.id == pref); + if (j >= 0) return j; + } + return 0; + } + + List _chatsForPageIndex(int pageIndex) { + if (_folders.isEmpty) return _chats; + if (pageIndex < 0 || pageIndex >= _folders.length) return _chats; + final folder = _folders[pageIndex]; + if (FoldersModule.isAllChatsFolder(folder)) return _chats; + return _chats + .where((c) => FoldersModule.chatMatchesFolder(c, folder)) + .toList(); + } + + void _syncFolderChatScrollControllers() { + _syncFolderChatScrollControllersForCount(_folderPageCount); + } + + void _syncFolderChatScrollControllersForCount(int n) { + while (_folderChatScrollControllers.length < n) { + final i = _folderChatScrollControllers.length; + void fn() => _onFolderChatScrollAt(i); + final c = ScrollController(); + c.addListener(fn); + _folderChatScrollControllers.add(c); + _folderChatScrollListenerFns.add(fn); + } + while (_folderChatScrollControllers.length > n) { + final c = _folderChatScrollControllers.removeLast(); + final fn = _folderChatScrollListenerFns.removeLast(); + c.removeListener(fn); + c.dispose(); + } + } + + bool _isChatScrollControllerActive(int index) { + if (_folderPageCount <= 1) return index == 0; + if (!_folderPageController.hasClients) { + return index == _selectedFolderIndex; + } + final p = _folderPageController.page; + if (p == null) return index == _selectedFolderIndex; + final r = p.round().clamp(0, _folderPageCount - 1); + return r == index; + } + + void _onFolderChatScrollAt(int index) { + if (!_isChatScrollControllerActive(index)) return; + if (index < 0 || index >= _folderChatScrollControllers.length) return; + final c = _folderChatScrollControllers[index]; + _applyChatScrollOffset(c); + } + + void _applyChatScrollOffset(ScrollController c) { + if (!c.hasClients) return; + final double offset = c.offset; + if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) { + setState(() { + _shouldCollapseSearch = true; + }); + } + + if (offset < 0) { + if (!_allowStoriesPullOverscrollTop()) { + return; + } + final dragRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); + if (_storiesRevealController.isAnimating) { + return; + } + if (!_storiesDockedOpen && offset.abs() >= _kStoriesPullTriggerPx) { + _startStoriesAutoReveal(dragRatio); + } else if (!_storiesDockedOpen) { + if (dragRatio != _pullRatio) { + setState(() { + _pullRatio = dragRatio; + }); + } + } + } else { + if (_storiesDockedOpen && + offset > 12 && + DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { + _startStoriesAutoClose(); + } + if (_storiesDockedOpen || _storiesRevealController.isAnimating) { + return; + } + final disarm = offset > 3 && _storiesOverscrollRevealArmed; + final clearPull = _pullRatio > 0; + if (disarm || clearPull) { + setState(() { + if (disarm) { + _storiesOverscrollRevealArmed = false; + } + if (clearPull) { + _pullRatio = 0.0; + } + }); + } + } + } + + ScrollController? _activeChatScrollController() { + if (_folderChatScrollControllers.isEmpty) return null; + if (!_folderPageController.hasClients) { + return _folderChatScrollControllers.first; + } + final p = _folderPageController.page; + final i = (p != null + ? p.round() + : _selectedFolderIndex) + .clamp(0, _folderChatScrollControllers.length - 1); + return _folderChatScrollControllers[i]; + } + + void _jumpFolderPageToSelection() { + if (!_folderPageController.hasClients) return; + final target = _folderIndexForId(_selectedFolderId); + final current = _folderPageController.page?.round(); + if (current != target) { + _folderPageController.jumpToPage(target); + } + } + String _formatTime(int? timestamp) { if (timestamp == null || timestamp == 0) return ''; final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); @@ -351,12 +548,11 @@ class _ChatListScreenState extends State _storiesRevealController.forward(from: 0); } - bool _onStoriesScrollNotification(ScrollNotification n) { + bool _handleStoriesScrollNotification(ScrollNotification n) { if (_currentNavIndex != 0) return false; - if (!_scrollController.hasClients) return false; if (n is ScrollEndNotification) { - if (_scrollController.offset <= 0.5) { + if (n.metrics.pixels <= 0.5) { setState(() { _storiesOverscrollRevealArmed = true; }); @@ -394,59 +590,9 @@ class _ChatListScreenState extends State return false; } - void _onScroll() { - if (_scrollController.hasClients) { - final double offset = _scrollController.offset; - if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) { - setState(() { - _shouldCollapseSearch = true; - }); - } - - if (offset < 0) { - if (!_allowStoriesPullOverscrollTop()) { - return; - } - final dragRatio = (offset.abs() / 80.0).clamp(0.0, 1.0); - if (_storiesRevealController.isAnimating) { - return; - } - if (!_storiesDockedOpen && offset.abs() >= _kStoriesPullTriggerPx) { - _startStoriesAutoReveal(dragRatio); - } else if (!_storiesDockedOpen) { - if (dragRatio != _pullRatio) { - setState(() { - _pullRatio = dragRatio; - }); - } - } - } else { - if (_storiesDockedOpen && - offset > 12 && - DateTime.now().isAfter(_storiesRevealLayoutSettleUntil)) { - _startStoriesAutoClose(); - } - if (_storiesDockedOpen || _storiesRevealController.isAnimating) { - return; - } - final disarm = offset > 3 && _storiesOverscrollRevealArmed; - final clearPull = _pullRatio > 0; - if (disarm || clearPull) { - setState(() { - if (disarm) { - _storiesOverscrollRevealArmed = false; - } - if (clearPull) { - _pullRatio = 0.0; - } - }); - } - } - } - } - @override void dispose() { + _loginSub?.cancel(); _stateSub?.cancel(); _fabController.dispose(); _navPageAnimController.dispose(); @@ -454,7 +600,13 @@ class _ChatListScreenState extends State ..removeListener(_onStoriesRevealTick) ..removeStatusListener(_onStoriesRevealStatus) ..dispose(); - _scrollController.dispose(); + _folderPageController.dispose(); + while (_folderChatScrollControllers.isNotEmpty) { + final c = _folderChatScrollControllers.removeLast(); + final fn = _folderChatScrollListenerFns.removeLast(); + c.removeListener(fn); + c.dispose(); + } super.dispose(); } @@ -727,31 +879,85 @@ class _ChatListScreenState extends State ui.PointerDeviceKind.trackpad, }, ), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, - ), - physics: const BouncingScrollPhysics(), - children: [ - _buildFolderChip('Все чаты'), - const SizedBox(width: 8), - _buildFolderChip('Контакты'), - const SizedBox(width: 8), - _buildFolderChip('Пидоры'), - const SizedBox(width: 8), - _buildFolderChip('Каналы'), - const SizedBox(width: 8), - _buildFolderChip('Группы'), - const SizedBox(width: 8), - _buildFolderChip('Боты'), - const SizedBox(width: 8), - _buildFolderChip('Избранное'), - const SizedBox(width: 8), - _buildFolderChip('Архив'), - ], - ), + child: _showFoldersShimmer + ? _buildFolderStripShimmer(cs) + : ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + physics: const BouncingScrollPhysics(), + children: [ + for (var i = 0; i < _folders.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _buildFolderChip( + _folderChipLabel(_folders[i]), + folderId: _folders[i].id, + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildFolderChatPage(int pageIndex) { + final chats = _chatsForPageIndex(pageIndex); + final sc = _folderChatScrollControllers[pageIndex]; + return NotificationListener( + onNotification: (ScrollNotification n) { + if (_currentNavIndex != 0) return false; + if (!_folderPageController.hasClients) { + if (pageIndex != _selectedFolderIndex) return false; + } else { + final p = _folderPageController.page; + if (p == null) { + if (pageIndex != _selectedFolderIndex) return false; + } else { + final r = p.round().clamp(0, _folderPageCount - 1); + if (r != pageIndex) return false; + } + } + return _handleStoriesScrollNotification(n); + }, + child: CustomScrollView( + controller: sc, + physics: _StoriesScrollPhysics( + blockPositive: _shouldBlockPositiveScroll, + allowPullOverscrollTop: _allowStoriesPullOverscrollTop, + parent: const AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, + childCount: _isInitialLoading ? 10 : chats.length, + ), + ), + SliverPadding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewPaddingOf(context).bottom + 100, ), ), ], @@ -772,7 +978,8 @@ class _ChatListScreenState extends State const Duration(milliseconds: 300), ); } - if (_scrollController.hasClients && _scrollController.offset <= 0) { + final ac = _activeChatScrollController(); + if (ac != null && ac.hasClients && ac.offset <= 0) { if (pointerSignal.scrollDelta.dy < 0) { if (_allowStoriesPullOverscrollTop()) { _startStoriesAutoReveal(max(_pullRatio, 0.18)); @@ -783,51 +990,30 @@ class _ChatListScreenState extends State } } }, - child: NotificationListener( - onNotification: _onStoriesScrollNotification, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildPinnedChatsHeader(context), - Expanded( - child: CustomScrollView( - controller: _scrollController, - physics: _StoriesScrollPhysics( - blockPositive: _shouldBlockPositiveScroll, - allowPullOverscrollTop: _allowStoriesPullOverscrollTop, - parent: const AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = _chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, childCount: _isInitialLoading ? 10 : _chats.length), - ), - SliverPadding( - padding: EdgeInsets.only( - bottom: MediaQuery.viewPaddingOf(context).bottom + 100, - ), - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPinnedChatsHeader(context), + Expanded( + child: PageView.builder( + controller: _folderPageController, + physics: _folderPageCount <= 1 + ? const NeverScrollableScrollPhysics() + : const BouncingScrollPhysics(), + onPageChanged: (i) { + if (_folders.isEmpty) return; + if (i < 0 || i >= _folders.length) return; + setState(() { + _selectedFolderId = _folders[i].id; + }); + }, + itemCount: _folderPageCount, + itemBuilder: (context, pageIndex) { + return _buildFolderChatPage(pageIndex); + }, ), - ], - ), + ), + ], ), ); } @@ -1272,11 +1458,70 @@ class _ChatListScreenState extends State ); } - Widget _buildFolderChip(String title) { + String _folderChipLabel(ChatFolder f) { + final e = f.emoji; + if (e != null && e.isNotEmpty) return '$e ${f.title}'; + return f.title; + } + + Widget _buildFolderStripShimmer(ColorScheme cs) { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.3 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + physics: const BouncingScrollPhysics(), + children: [ + _folderShimmerPill(cs, 88), + const SizedBox(width: 8), + _folderShimmerPill(cs, 72), + const SizedBox(width: 8), + _folderShimmerPill(cs, 96), + const SizedBox(width: 8), + _folderShimmerPill(cs, 64), + const SizedBox(width: 8), + _folderShimmerPill(cs, 80), + ], + ), + ); + }, + ); + } + + Widget _folderShimmerPill(ColorScheme cs, double width) { + return Container( + width: width, + height: 32, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + ); + } + + Widget _buildFolderChip(String title, {required String folderId}) { final cs = Theme.of(context).colorScheme; - bool isSelected = _selectedCategory == title; + final isSelected = _selectedFolderId == folderId; return GestureDetector( - onTap: () => setState(() => _selectedCategory = title), + onTap: () { + final i = _folders.indexWhere((f) => f.id == folderId); + if (i < 0) return; + setState(() => _selectedFolderId = folderId); + if (_folderPageController.hasClients) { + final cur = _folderPageController.page?.round(); + if (cur != i) { + _folderPageController.animateToPage( + i, + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } + } + }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( From 87f9d6aeb8b5041ec3ff37afb16ef298c6c29964 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 4 Apr 2026 22:05:50 +0700 Subject: [PATCH 20/59] =?UTF-8?q?=D1=83=20=D0=BC=D0=B5=D0=BD=D1=8F=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BC=D0=B5=D0=BD=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/calls.dart | 147 ++++++++ lib/backend/modules/contacts.dart | 41 +++ lib/frontend/screens/calls/calls_tab.dart | 345 +++++++++++++++++- .../screens/contacts/contacts_tab.dart | 194 +++++++++- 4 files changed, 709 insertions(+), 18 deletions(-) diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index e69de29..2182af0 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -0,0 +1,147 @@ +// Backend module for parsing calls from Komet platform +import '../../core/storage/app_database.dart'; +import 'contacts.dart'; +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; + +enum CallStatus { missed, canceled, outgoing, incoming } + +class CallLogEntry { + final String id; + final int accountId; + final int peerId; + final String name; + final String? avatarUrl; + final CallStatus status; + final int time; + final int count; + + const CallLogEntry({ + required this.id, + required this.accountId, + required this.peerId, + required this.name, + this.avatarUrl, + required this.status, + required this.time, + this.count = 1, + }); +} + +class CallsModule { + final Api _api; + + CallsModule(this._api); + + /// Fetch call history from opcode 79 + Future> fetchHistory( + int accountId, + int currentUserId, + ) async { + final response = await _api.sendRequest(Opcode.videoChatHistory, {}); + if (!response.isOk || response.payload is! Map) return []; + + final payload = response.payload as Map; + return parseHistoryPayload(payload, accountId, currentUserId); + } + + /// Парсинг истории звонков (opcode 79: videoChatHistory) + static Future> parseHistoryPayload( + Map payload, + int accountId, + int currentUserId, + ) async { + final history = payload['history']; + if (history is! List || history.isEmpty) return []; + + final recentContacts = await ContactsModule.getContacts(accountId); + final contactsMap = {for (final c in recentContacts) c.id: c}; + + print('DEBUG: Loaded ${recentContacts.length} contacts'); + print('DEBUG: Contact IDs: ${contactsMap.keys.toList()}'); + print('DEBUG: Current user ID: $currentUserId'); + + final List extractedCalls = []; + + for (final item in history.whereType()) { + final msg = item['message']; + if (msg is! Map) continue; + + final attaches = msg['attaches']; + if (attaches is! List || attaches.isEmpty) continue; + + final callAttach = attaches.firstWhere( + (a) => a is Map && a['_type'] == 'CALL', + orElse: () => null, + ); + + if (callAttach == null) continue; + + final senderId = (msg['sender'] as int?) ?? 0; + final isOutgoing = senderId == currentUserId; + + int peerId = 0; + if (isOutgoing) { + final contactIds = callAttach['contactIds']; + if (contactIds is List && contactIds.isNotEmpty) { + peerId = (contactIds.first as int?) ?? 0; + } + } else { + peerId = senderId; + } + + print( + 'DEBUG: Call - isOutgoing: $isOutgoing, peerId: $peerId, senderId: $senderId', + ); + final contact = contactsMap[peerId]; + print( + 'DEBUG: Contact found: ${contact != null}, firstName: "${contact?.firstName}", lastName: "${contact?.lastName}"', + ); + final status = _parseCallStatus(callAttach, isOutgoing); + final time = (msg['time'] as int?) ?? 0; + final msgId = + msg['id']?.toString() ?? + DateTime.now().millisecondsSinceEpoch.toString(); + + final name = contact?.firstName != null + ? '${contact!.firstName} ${contact.lastName ?? ''}'.trim() + : 'Неизвестный'; + + print('DEBUG: Creating CallLogEntry with name: "$name"'); + + extractedCalls.add( + CallLogEntry( + id: msgId, + accountId: accountId, + peerId: peerId, + name: name, + avatarUrl: contact?.baseUrl, + status: status, + time: time, + ), + ); + } + + return extractedCalls; + } + + static CallStatus _parseCallStatus( + Map callAttach, + bool isOutgoing, + ) { + final hangupType = callAttach['hangupType']; + final duration = (callAttach['duration'] as int?) ?? 0; + + if (isOutgoing) { + if (hangupType == 'CANCELED' || duration == 0) return CallStatus.canceled; + return CallStatus.outgoing; + } else { + if (hangupType == 'CANCELED' || + hangupType == 'REJECTED' || + hangupType == 'MISSED' || + duration == 0) + return CallStatus.missed; + return CallStatus.incoming; + } + } +} diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 7d6614a..1943e52 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -1,5 +1,41 @@ import '../../core/storage/app_database.dart'; +class CachedContact { + final int id; + final int accountId; + final String firstName; + final String? lastName; + final int phone; + final int? photoId; + final String? baseUrl; + final String? baseRawUrl; + final int updateTime; + + const CachedContact({ + required this.id, + required this.accountId, + required this.firstName, + this.lastName, + required this.phone, + this.photoId, + this.baseUrl, + this.baseRawUrl, + required this.updateTime, + }); + + factory CachedContact.fromDbRow(Map row) => CachedContact( + id: row['id'] as int, + accountId: row['account_id'] as int, + firstName: row['first_name'] as String, + lastName: row['last_name'] as String?, + phone: row['phone'] as int, + photoId: row['photo_id'] as int?, + baseUrl: row['base_url'] as String?, + baseRawUrl: row['base_raw_url'] as String?, + updateTime: row['update_time'] as int, + ); +} + class ContactsModule { static Future syncFromLoginPayload( Map data, @@ -19,6 +55,11 @@ class ContactsModule { } } + static Future> getContacts(int accountId) async { + final rows = await AppDatabase.loadContacts(accountId); + return rows.map(CachedContact.fromDbRow).toList(); + } + static Map? _parseContact( Map contact, int accountId, diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 21bf27b..241bc6a 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -1,19 +1,346 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show api; +import '../../../core/storage/app_database.dart'; +import '../../../backend/modules/calls.dart'; -class CallsTab extends StatelessWidget { +class CallsTab extends StatefulWidget { const CallsTab({super.key}); + @override + State createState() => _CallsTabState(); +} + +class _CallsTabState extends State { + List _calls = []; + bool _isLoading = true; + int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные' + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _loadHistory() async { + final p = await AppDatabase.loadActiveProfile(); + if (p == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + + final callsModule = CallsModule(api); + final calls = await callsModule.fetchHistory(p.id, p.id); + + print('DEBUG UI: Received ${calls.length} calls'); + for (final call in calls.take(3)) { + print('DEBUG UI: Call name="${call.name}", peerId=${call.peerId}'); + } + + // Группируем подряд идущие звонки одному и тому же абоненту в один день с одним и тем же статусом + final List grouped = []; + for (final call in calls) { + if (grouped.isNotEmpty && + grouped.last.peerId == call.peerId && + grouped.last.status == call.status && + _isSameDay(grouped.last.time, call.time)) { + final last = grouped.removeLast(); + grouped.add( + CallLogEntry( + id: last.id, + accountId: last.accountId, + peerId: last.peerId, + name: last.name, + avatarUrl: last.avatarUrl, + status: last.status, + time: last.time, + count: last.count + 1, + ), + ); + } else { + grouped.add(call); + } + } + + if (mounted) { + setState(() { + _calls = grouped; + _isLoading = false; + }); + } + } + + bool _isSameDay(int time1, int time2) { + if (time1 == 0 || time2 == 0) return false; + final d1 = DateTime.fromMillisecondsSinceEpoch(time1); + final d2 = DateTime.fromMillisecondsSinceEpoch(time2); + return d1.year == d2.year && d1.month == d2.month && d1.day == d2.day; + } + + String _formatDate(int timestamp) { + if (timestamp == 0) return ''; + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final months = [ + 'янв.', + 'фев.', + 'мар.', + 'апр.', + 'мая', + 'июн.', + 'июл.', + 'авг.', + 'сен.', + 'окт.', + 'ноя.', + 'дек.', + ]; + return '${dt.day} ${months[dt.month - 1]}'; + } + + Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { + return Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildCallItem( + BuildContext context, + ColorScheme cs, + CallLogEntry call, + ) { + final bool isMissed = call.status == CallStatus.missed; + + String statusText; + IconData statusIcon; + switch (call.status) { + case CallStatus.missed: + statusText = 'Пропущенный'; + statusIcon = Symbols.phone_missed; + break; + case CallStatus.canceled: + statusText = 'Отменённый'; + statusIcon = Symbols.phone_disabled; + break; + case CallStatus.outgoing: + statusText = 'Исходящий'; + statusIcon = Symbols.call_made; + break; + case CallStatus.incoming: + statusText = 'Входящий'; + statusIcon = Symbols.call_received; + break; + } + + final String displayName = call.count > 1 + ? '${call.name} (${call.count})' + : call.name; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Open call details or initiate call + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.1), + width: 1, + ), + ), + child: ClipOval( + child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty + ? Image.network( + call.avatarUrl!, + fit: BoxFit.cover, + errorBuilder: (context, _, ___) => + _buildPlaceholderAvatar(cs, call.name), + ) + : _buildPlaceholderAvatar(cs, call.name), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: TextStyle( + color: isMissed ? cs.error : cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(statusIcon, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + statusText, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + _formatDate(call.time), + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + fontSize: 12, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTabItem(String label, int index, ColorScheme cs) { + final isSelected = _selectedTabIndex == index; + return GestureDetector( + onTap: () { + setState(() { + _selectedTabIndex = index; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected ? cs.primary : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? cs.primary : cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Center( - child: Text( - 'Звонки (Заглушка)', - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w500, + + final filteredCalls = _selectedTabIndex == 1 + ? _calls.where((c) => c.status == CallStatus.missed).toList() + : _calls; + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + 'Звонки', + style: TextStyle( + color: cs.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + InkWell( + onTap: () {}, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + child: Row( + children: [ + Icon(Symbols.link, color: cs.primary, size: 24), + const SizedBox(width: 16), + Text( + 'Создать групповой звонок', + style: TextStyle( + color: cs.primary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + child: Row( + children: [ + _buildTabItem('Все', 0, cs), + const SizedBox(width: 8), + _buildTabItem('Пропущенные', 1, cs), + ], + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : filteredCalls.isEmpty + ? Center( + child: Text( + 'Нет звонков', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 120), + itemCount: filteredCalls.length, + itemBuilder: (context, index) { + return _buildCallItem( + context, + cs, + filteredCalls[index], + ); + }, + ), + ), + ], ), ), ); diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index c47b562..fffa416 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -1,19 +1,195 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../backend/modules/contacts.dart'; -class ContactsTab extends StatelessWidget { +class ContactsTab extends StatefulWidget { const ContactsTab({super.key}); + @override + State createState() => _ContactsTabState(); +} + +class _ContactsTabState extends State { + List _contacts = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadContacts(); + } + + Future _loadContacts() async { + final p = await AppDatabase.loadActiveProfile(); + if (p == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + final contacts = await ContactsModule.getContacts(p.id); + // Sort contacts by first name + contacts.sort((a, b) => a.firstName.compareTo(b.firstName)); + if (mounted) { + setState(() { + _contacts = contacts; + _isLoading = false; + }); + } + } + + Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { + return Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildContactItem( + BuildContext context, + ColorScheme cs, + CachedContact contact, + ) { + final fullName = + '${contact.firstName}${contact.lastName != null ? ' ${contact.lastName}' : ''}' + .trim(); + final nameToDisplay = fullName.isEmpty ? '+${contact.phone}' : fullName; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Open contact details or chat + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.1), + width: 1, + ), + ), + child: ClipOval( + child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty + ? Image.network( + contact.baseUrl!, + fit: BoxFit.cover, + errorBuilder: (context, _, ___) => + _buildPlaceholderAvatar(cs, nameToDisplay), + ) + : _buildPlaceholderAvatar(cs, nameToDisplay), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nameToDisplay, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + contact.updateTime > 0 + ? 'Был(а) недавно' + : '+${contact.phone}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Center( - child: Text( - 'Контакты (Заглушка)', - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w500, + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Row( + children: [ + Expanded( + child: Text( + 'Контакты', + style: TextStyle( + color: cs.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + IconButton( + icon: Icon(Symbols.person_add, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.search, color: cs.onSurface), + onPressed: () {}, + ), + ], + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _contacts.isEmpty + ? Center( + child: Text( + 'Нет контактов', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 120), + itemCount: _contacts.length, + itemBuilder: (context, index) { + final contact = _contacts[index]; + return _buildContactItem(context, cs, contact); + }, + ), + ), + ], ), ), ); From 491bb9c23e662f298c24fc047ca5af07a80bdf11 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 4 Apr 2026 22:15:55 +0700 Subject: [PATCH 21/59] Fix code analysis issues: remove unused imports, debug prints, fix deprecated methods and naming conventions --- lib/backend/api.dart | 3 +- lib/backend/modules/calls.dart | 16 +---- lib/core/protocol/opcode_map.dart | 6 +- lib/core/protocol/packet.dart | 6 +- lib/core/storage/app_database.dart | 1 - lib/frontend/screens/calls/calls_tab.dart | 8 +-- .../screens/chats/chat_list_screen.dart | 62 +++++++++---------- .../screens/contacts/contacts_tab.dart | 2 +- .../screens/profile/settings_tab.dart | 2 +- lib/main.dart | 2 +- 10 files changed, 42 insertions(+), 66 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 72f987c..6a84839 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -14,7 +14,6 @@ import '../core/utils/logger.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'dart:io'; import 'package:timezone/data/latest_all.dart' as tz; -import 'package:timezone/timezone.dart' as tz; import 'package:flutter_timezone/flutter_timezone.dart'; enum SessionState { disconnected, connecting, connected, online } @@ -279,7 +278,7 @@ class Api { final delaySec = (2 * (1 << _reconnectAttempts)).clamp(2, 30); _reconnectAttempts++; - logger.i('Реконнект через ${delaySec}с (попытка $_reconnectAttempts)'); + logger.i('Реконнект через $delaySecс (попытка $_reconnectAttempts)'); _reconnectTimer?.cancel(); _reconnectTimer = Timer(Duration(seconds: delaySec), connect); diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 2182af0..4593e48 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -1,5 +1,4 @@ // Backend module for parsing calls from Komet platform -import '../../core/storage/app_database.dart'; import 'contacts.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; @@ -57,10 +56,6 @@ class CallsModule { final recentContacts = await ContactsModule.getContacts(accountId); final contactsMap = {for (final c in recentContacts) c.id: c}; - print('DEBUG: Loaded ${recentContacts.length} contacts'); - print('DEBUG: Contact IDs: ${contactsMap.keys.toList()}'); - print('DEBUG: Current user ID: $currentUserId'); - final List extractedCalls = []; for (final item in history.whereType()) { @@ -90,13 +85,7 @@ class CallsModule { peerId = senderId; } - print( - 'DEBUG: Call - isOutgoing: $isOutgoing, peerId: $peerId, senderId: $senderId', - ); final contact = contactsMap[peerId]; - print( - 'DEBUG: Contact found: ${contact != null}, firstName: "${contact?.firstName}", lastName: "${contact?.lastName}"', - ); final status = _parseCallStatus(callAttach, isOutgoing); final time = (msg['time'] as int?) ?? 0; final msgId = @@ -107,8 +96,6 @@ class CallsModule { ? '${contact!.firstName} ${contact.lastName ?? ''}'.trim() : 'Неизвестный'; - print('DEBUG: Creating CallLogEntry with name: "$name"'); - extractedCalls.add( CallLogEntry( id: msgId, @@ -139,8 +126,9 @@ class CallsModule { if (hangupType == 'CANCELED' || hangupType == 'REJECTED' || hangupType == 'MISSED' || - duration == 0) + duration == 0) { return CallStatus.missed; + } return CallStatus.incoming; } } diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index 75cc983..edc40e5 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -2,7 +2,7 @@ /// /// Naming follows the server-side convention. /// Use [Opcode.name] to get a human-readable label for logging. -/// +/// /// файл писла нейронка (я че ебанутый чтоль чтобы вручную хуярить опкоды и их значения) abstract class Opcode { // ── Session ──────────────────────────────────────────────────────── @@ -124,7 +124,7 @@ abstract class Opcode { // ── Sessions ─────────────────────────────────────────────────────── static const int sessionsInfo = 96; // Запрос активных сессий static const int sessionsClose = 97; // Закрытие всех сессий - static const int phoneBind_request = 98; // Запрос привязки телефона + static const int phoneBindRequest = 98; // Запрос привязки телефона static const int phoneBindConfirm = 99; // Подтверждение привязки телефона // ── Bots ─────────────────────────────────────────────────────────── @@ -297,7 +297,7 @@ abstract class Opcode { audioPlay: 'AUDIO_PLAY', sessionsInfo: 'SESSIONS_INFO', sessionsClose: 'SESSIONS_CLOSE', - phoneBind_request: 'PHONE_BIND_REQUEST', + phoneBindRequest: 'PHONE_BIND_REQUEST', phoneBindConfirm: 'PHONE_BIND_CONFIRM', chatComplain: 'CHAT_COMPLAIN', msgSendCallback: 'MSG_SEND_CALLBACK', diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 8bd0bc4..7a0a320 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -137,9 +137,7 @@ Future unpackPacket(Uint8List packet) async { _maxDecompressedSize, ); } catch (e) { - // В изоляте нельзя использовать логгер, который пишет в терминал через зависимости Flutter, - // но простой print или throw сработает - print("LZ4 decompression error: $e"); + throw Exception("LZ4 decompression error: $e"); } } } @@ -148,7 +146,7 @@ Future unpackPacket(Uint8List packet) async { payload = msgpack.deserialize(payloadBytes); } catch (e) { if (payloadBytes.isNotEmpty) { - print("MsgPack deserialization error: $e"); + throw Exception("MsgPack deserialization error: $e"); } } } diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 97b80a9..ccd6191 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:path/path.dart'; -import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; class ProfileData { diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 241bc6a..09edce7 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -32,12 +32,6 @@ class _CallsTabState extends State { final callsModule = CallsModule(api); final calls = await callsModule.fetchHistory(p.id, p.id); - print('DEBUG UI: Received ${calls.length} calls'); - for (final call in calls.take(3)) { - print('DEBUG UI: Call name="${call.name}", peerId=${call.peerId}'); - } - - // Группируем подряд идущие звонки одному и тому же абоненту в один день с одним и тем же статусом final List grouped = []; for (final call in calls) { if (grouped.isNotEmpty && @@ -169,7 +163,7 @@ class _CallsTabState extends State { ? Image.network( call.avatarUrl!, fit: BoxFit.cover, - errorBuilder: (context, _, ___) => + errorBuilder: (context, error, stackTrace) => _buildPlaceholderAvatar(cs, call.name), ) : _buildPlaceholderAvatar(cs, call.name), diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 321f739..1db8a08 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -4,7 +4,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'dart:math'; import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; import 'chat_screen.dart'; import '../calls/calls_tab.dart'; @@ -25,8 +24,8 @@ class _StoriesScrollPhysics extends BouncingScrollPhysics { const _StoriesScrollPhysics({ required this.blockPositive, required this.allowPullOverscrollTop, - ScrollPhysics? parent, - }) : super(parent: parent); + super.parent, + }); @override _StoriesScrollPhysics applyTo(ScrollPhysics? ancestor) { @@ -382,10 +381,10 @@ class _ChatListScreenState extends State return _folderChatScrollControllers.first; } final p = _folderPageController.page; - final i = (p != null - ? p.round() - : _selectedFolderIndex) - .clamp(0, _folderChatScrollControllers.length - 1); + final i = (p != null ? p.round() : _selectedFolderIndex).clamp( + 0, + _folderChatScrollControllers.length - 1, + ); return _folderChatScrollControllers[i]; } @@ -795,10 +794,10 @@ class _ChatListScreenState extends State vertical: 8, ), decoration: BoxDecoration( - color: cs.errorContainer.withOpacity(0.3), + color: cs.errorContainer.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(12), border: Border.all( - color: cs.error.withOpacity(0.2), + color: cs.error.withValues(alpha: 0.2), ), ), child: Row( @@ -933,27 +932,24 @@ class _ChatListScreenState extends State ), slivers: [ SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, - childCount: _isInitialLoading ? 10 : chats.length, - ), + delegate: SliverChildBuilderDelegate((context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, childCount: _isInitialLoading ? 10 : chats.length), ), SliverPadding( padding: EdgeInsets.only( @@ -1371,7 +1367,7 @@ class _ChatListScreenState extends State color: cs.surface, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 10, offset: const Offset(0, 2), ), @@ -1575,7 +1571,9 @@ class _ChatListScreenState extends State onLongPress: () => _toggleSelection(id), child: AnimatedContainer( duration: const Duration(milliseconds: 200), - color: isSelected ? cs.primary.withOpacity(0.08) : Colors.transparent, + color: isSelected + ? cs.primary.withValues(alpha: 0.08) + : Colors.transparent, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index fffa416..3d86580 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -87,7 +87,7 @@ class _ContactsTabState extends State { ? Image.network( contact.baseUrl!, fit: BoxFit.cover, - errorBuilder: (context, _, ___) => + errorBuilder: (context, error, stackTrace) => _buildPlaceholderAvatar(cs, nameToDisplay), ) : _buildPlaceholderAvatar(cs, nameToDisplay), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 1f6a0ba..6efef04 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -151,7 +151,7 @@ class _SettingsTabState extends State { ? Image.network( _profile!.baseUrl!, fit: BoxFit.cover, - errorBuilder: (context, _, __) => + errorBuilder: (context, error, stackTrace) => _buildPlaceholderAvatar(cs, name), ) : _buildPlaceholderAvatar(cs, name), diff --git a/lib/main.dart b/lib/main.dart index e95dcfa..979aec2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -74,7 +74,7 @@ class KometAppState extends State { final navState = KometApp.navigatorKey.currentState; if (navState != null) { final overlayContext = navState.overlay?.context; - if (overlayContext != null) { + if (overlayContext != null && overlayContext.mounted) { showCustomNotification(overlayContext, e.message); } From 05e7ba9cc61512e0ade8ed930c98ada9fe35282b Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 4 Apr 2026 23:50:04 +0700 Subject: [PATCH 22/59] =?UTF-8?q?=D1=87=D0=B5=D1=82=D0=BE=20=D1=81=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BF=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=BD=D0=B0=D1=85?= =?UTF-8?q?=D1=83=D0=B5=D0=B2=D1=91=D1=80=D1=82=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 168 ++++++++++++------ 1 file changed, 115 insertions(+), 53 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1db8a08..4ac2dc8 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -201,8 +201,21 @@ class _ChatListScreenState extends State final p = await AppDatabase.loadActiveProfile(); if (p != null) { final chats = await ChatsModule.getChats(p.id); - final folders = await FoldersModule.loadFolders(p.id); + var folders = await FoldersModule.loadFolders(p.id); final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); + + final allChatsFolder = ChatFolder( + id: 'all.chat.folder', + title: 'Все чаты', + filters: [], + hideEmpty: false, + widgets: [], + ); + + if (!folders.any((f) => FoldersModule.isAllChatsFolder(f))) { + folders = [allChatsFolder, ...folders]; + } + final pageCount = folders.isEmpty ? 1 : folders.length; _syncFolderChatScrollControllersForCount(pageCount); if (mounted) { @@ -865,40 +878,75 @@ class _ChatListScreenState extends State ), ), ), - AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeOutCubic, - height: 48, - color: cs.surface, - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith( - dragDevices: { - ui.PointerDeviceKind.touch, - ui.PointerDeviceKind.mouse, - ui.PointerDeviceKind.trackpad, - }, - ), - child: _showFoldersShimmer - ? _buildFolderStripShimmer(cs) - : ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, + if (_folders.length > 1) + AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + height: 48, + color: cs.surface, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: { + ui.PointerDeviceKind.touch, + ui.PointerDeviceKind.mouse, + ui.PointerDeviceKind.trackpad, + }, + ), + child: _showFoldersShimmer + ? _buildFolderStripShimmer(cs) + : LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth - 40; + final folderCount = _folders.length; + final minWidthPerFolder = 80.0; + final totalMinWidth = + folderCount * minWidthPerFolder + + (folderCount - 1) * 8; + final needsScroll = totalMinWidth > availableWidth; + + if (needsScroll) { + return ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + physics: const BouncingScrollPhysics(), + children: [ + for (var i = 0; i < _folders.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _buildFolderChip( + _folderChipLabel(_folders[i]), + folderId: _folders[i].id, + ), + ], + ], + ); + } else { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + child: Row( + children: [ + for (var i = 0; i < _folders.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded( + child: _buildFolderChip( + _folderChipLabel(_folders[i]), + folderId: _folders[i].id, + ), + ), + ], + ], + ), + ); + } + }, ), - physics: const BouncingScrollPhysics(), - children: [ - for (var i = 0; i < _folders.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - _buildFolderChip( - _folderChipLabel(_folders[i]), - folderId: _folders[i].id, - ), - ], - ], - ), + ), ), - ), ], ), ); @@ -907,6 +955,7 @@ class _ChatListScreenState extends State Widget _buildFolderChatPage(int pageIndex) { final chats = _chatsForPageIndex(pageIndex); final sc = _folderChatScrollControllers[pageIndex]; + final cs = Theme.of(context).colorScheme; return NotificationListener( onNotification: (ScrollNotification n) { if (_currentNavIndex != 0) return false; @@ -931,26 +980,39 @@ class _ChatListScreenState extends State parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (_isInitialLoading) { - return _buildChatShimmer(); - } - final chat = chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); - }, childCount: _isInitialLoading ? 10 : chats.length), - ), + if (chats.isEmpty && !_isInitialLoading) + SliverFillRemaining( + child: Center( + child: Text( + 'Кажется, тут пусто...', + style: TextStyle( + color: cs.onSurface.withOpacity(0.6), + fontSize: 16, + ), + ), + ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (_isInitialLoading) { + return _buildChatShimmer(); + } + final chat = chats[index]; + return _buildChatItem( + chat.id.toString(), + chat.title ?? 'Чат', + chat.lastMsgText ?? '', + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + }, childCount: _isInitialLoading ? 10 : chats.length), + ), SliverPadding( padding: EdgeInsets.only( bottom: MediaQuery.viewPaddingOf(context).bottom + 100, From 6cede4a6941a527f93b617f81bd1fd5dfaf99e1f Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 5 Apr 2026 00:20:16 +0700 Subject: [PATCH 23/59] =?UTF-8?q?=D0=B2=D1=81=D1=91=20=D1=8F=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B0=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 +- .../screens/profile/security_screen.dart | 397 ++++++++++++++++++ .../screens/profile/settings_tab.dart | 24 +- .../screens/profile/spoof_screen.dart | 333 +++++++++++++++ 4 files changed, 755 insertions(+), 2 deletions(-) create mode 100644 lib/frontend/screens/profile/security_screen.dart create mode 100644 lib/frontend/screens/profile/spoof_screen.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 08303a5..31f08e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "cmake.sourceDirectory": "/run/media/invisedivine/Drive/! My projects/Komet/linux/runner" + "cmake.sourceDirectory": "/run/media/invisedivine/Drive/! My projects/Komet/linux/runner", + "kiroAgent.configureMCP": "Disabled" } \ No newline at end of file diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart new file mode 100644 index 0000000..a2c84d0 --- /dev/null +++ b/lib/frontend/screens/profile/security_screen.dart @@ -0,0 +1,397 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/custom_notification.dart'; + +class SecurityScreen extends StatefulWidget { + const SecurityScreen({super.key}); + + @override + State createState() => _SecurityScreenState(); +} + +class _SecurityScreenState extends State { + bool _safeMode = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildTopSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSafeModeSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + child: _buildInfoLabel(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildOnlineSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + child: _buildBlacklistSection(cs), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface, size: 24, weight: 400), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Text( + 'Безопасность', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ], + ), + ); + } + + Widget _buildTopSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + _buildNavRow( + cs, + icon: Symbols.key, + label: 'Пароль для входа', + subtitle: 'Отключён', + trailing: _buildWarningBadge(cs), + isLast: false, + ), + _buildNavRow( + cs, + icon: Symbols.shield, + label: 'Семейная защита', + subtitle: 'Отключена', + isLast: true, + ), + ], + ), + ); + } + + Widget _buildSafeModeSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon(Symbols.lock, 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: _safeMode, + onChanged: (v) => setState(() => _safeMode = v), + ), + ], + ), + ), + if (_safeMode) ...[ + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider(height: 1, thickness: 1, color: cs.outlineVariant.withValues(alpha: 0.35)), + ), + _buildSubRow(cs, label: 'Найти меня по номеру', value: 'Могут все', isLast: false), + _buildSubRow(cs, label: 'Позвонить', value: 'Могут все', isLast: false), + _buildSubRow(cs, label: 'Пригласить в чат', value: 'Могут контакты', isLast: false), + _buildSubRow(cs, label: 'Показывать контент', value: 'Весь', isLast: true), + ], + ], + ), + ); + } + + Widget _buildInfoLabel(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.only(left: 4, bottom: 0), + child: Text( + 'ИНФОРМАЦИЯ', + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ); + } + + Widget _buildOnlineSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: _buildNavRow( + cs, + icon: null, + label: 'Видеть статус «в сети»', + value: 'Никто', + isLast: true, + noIcon: true, + ), + ); + } + + Widget _buildBlacklistSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => showCustomNotification(context, 'Чёрный список'), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + 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, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon(Symbols.chevron_right, color: cs.outline, size: 20, weight: 400), + ], + ), + ), + ), + ), + ); + } + + Widget _buildNavRow( + ColorScheme cs, { + required IconData? icon, + required String label, + String? subtitle, + String? value, + Widget? trailing, + required bool isLast, + bool noIcon = false, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => showCustomNotification(context, label), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + if (!noIcon) ...[ + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), + ], + Expanded( + child: subtitle != null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ) + : Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + if (value != null) + Text( + value, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + ), + if (trailing != null) trailing, + const SizedBox(width: 4), + Icon(Symbols.chevron_right, color: cs.outline, size: 20, weight: 400), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider(height: 1, thickness: 1, color: cs.outlineVariant.withValues(alpha: 0.35)), + ), + ], + ); + } + + Widget _buildSubRow( + ColorScheme cs, { + required String label, + required String value, + required bool isLast, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => showCustomNotification(context, label), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(color: cs.onSurface, fontSize: 15), + ), + ), + Text( + value, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(width: 4), + Icon(Symbols.chevron_right, color: cs.outline, size: 18, weight: 400), + ], + ), + ), + ), + ), + if (!isLast) + Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + indent: 20, + endIndent: 20, + ), + ], + ); + } + + Widget _buildWarningBadge(ColorScheme cs) { + return Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: cs.error, + shape: BoxShape.circle, + ), + child: Icon(Symbols.priority_high, color: cs.onError, size: 14, weight: 700), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 6efef04..3dc116c 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; import 'devices_screen.dart'; +import 'security_screen.dart'; +import 'spoof_screen.dart'; class SettingsTab extends StatefulWidget { const SettingsTab({super.key}); @@ -75,9 +77,29 @@ class _SettingsTabState extends State { icon: Symbols.notifications_active, label: 'Уведомления и звук', ), - const _SettingsItem( + _SettingsItem( + icon: Symbols.shield_lock, + label: 'Подделка данных', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SpoofScreen(), + ), + ); + }, + ), + _SettingsItem( icon: Symbols.lock, label: 'Безопасность', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SecurityScreen(), + ), + ); + }, ), _SettingsItem( icon: Symbols.devices, diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart new file mode 100644 index 0000000..accabc4 --- /dev/null +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -0,0 +1,333 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../../core/config/spoof_data.dart'; +import '../../widgets/custom_notification.dart'; + +class SpoofScreen extends StatefulWidget { + const SpoofScreen({super.key}); + + @override + State createState() => _SpoofScreenState(); +} + +class _SpoofScreenState extends State { + final _deviceTypeController = TextEditingController(); + final _deviceNameController = TextEditingController(); + final _osVersionController = TextEditingController(); + final _screenController = TextEditingController(); + final _timezoneController = TextEditingController(); + final _localeController = TextEditingController(); + final _deviceLocaleController = TextEditingController(); + final _deviceIdController = TextEditingController(); + final _appVersionController = TextEditingController(); + final _buildNumberController = TextEditingController(); + final _architectureController = TextEditingController(); + final _pushDeviceTypeController = TextEditingController(); + final _mtInstanceIdController = TextEditingController(); + final _clientSessionIdController = TextEditingController(); + + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + final random = Random(); + + setState(() { + _deviceTypeController.text = prefs.getString('spoof_device_type') ?? SpoofData.deviceType; + _deviceNameController.text = prefs.getString('spoof_device_name') ?? + SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; + _osVersionController.text = prefs.getString('spoof_os_version') ?? + 'Android ${SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]}'; + _screenController.text = prefs.getString('spoof_screen') ?? + SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; + _timezoneController.text = prefs.getString('spoof_timezone') ?? SpoofData.timezone; + _localeController.text = prefs.getString('spoof_locale') ?? SpoofData.locale; + _deviceLocaleController.text = prefs.getString('spoof_device_locale') ?? 'ru'; + _deviceIdController.text = prefs.getString('spoof_device_id') ?? + SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; + _appVersionController.text = prefs.getString('spoof_app_version') ?? SpoofData.appVersion; + _buildNumberController.text = prefs.getString('spoof_build_number') ?? SpoofData.buildNumber; + _architectureController.text = prefs.getString('spoof_architecture') ?? + SpoofData.architectures[random.nextInt(SpoofData.architectures.length)]; + _pushDeviceTypeController.text = prefs.getString('spoof_push_device_type') ?? 'GCM'; + _mtInstanceIdController.text = prefs.getString('spoof_mt_instanceid') ?? + '550e8400-e29b-41d4-a716-446655440000'; + _clientSessionIdController.text = prefs.getString('spoof_client_session_id') ?? '42'; + _isLoading = false; + }); + } + + Future _saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('spoof_device_type', _deviceTypeController.text); + await prefs.setString('spoof_device_name', _deviceNameController.text); + await prefs.setString('spoof_os_version', _osVersionController.text); + await prefs.setString('spoof_screen', _screenController.text); + await prefs.setString('spoof_timezone', _timezoneController.text); + await prefs.setString('spoof_locale', _localeController.text); + await prefs.setString('spoof_device_locale', _deviceLocaleController.text); + await prefs.setString('spoof_device_id', _deviceIdController.text); + await prefs.setString('spoof_app_version', _appVersionController.text); + await prefs.setString('spoof_build_number', _buildNumberController.text); + await prefs.setString('spoof_architecture', _architectureController.text); + await prefs.setString('spoof_push_device_type', _pushDeviceTypeController.text); + await prefs.setString('spoof_mt_instanceid', _mtInstanceIdController.text); + await prefs.setString('spoof_client_session_id', _clientSessionIdController.text); + + if (mounted) { + showCustomNotification(context, 'Настройки сохранены'); + } + } + + Future _randomizeAll() async { + final random = Random(); + setState(() { + _deviceNameController.text = SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; + _osVersionController.text = 'Android ${SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]}'; + _screenController.text = SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; + _deviceIdController.text = SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; + _architectureController.text = SpoofData.architectures[random.nextInt(SpoofData.architectures.length)]; + }); + await _saveSettings(); + if (mounted) { + showCustomNotification(context, 'Данные рандомизированы'); + } + } + + @override + void dispose() { + _deviceTypeController.dispose(); + _deviceNameController.dispose(); + _osVersionController.dispose(); + _screenController.dispose(); + _timezoneController.dispose(); + _localeController.dispose(); + _deviceLocaleController.dispose(); + _deviceIdController.dispose(); + _appVersionController.dispose(); + _buildNumberController.dispose(); + _architectureController.dispose(); + _pushDeviceTypeController.dispose(); + _mtInstanceIdController.dispose(); + _clientSessionIdController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_isLoading) { + return Scaffold( + backgroundColor: cs.surface, + body: const Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildSection(cs, [ + _buildField(cs, 'Тип устройства', _deviceTypeController), + _buildField(cs, 'Имя устройства', _deviceNameController), + _buildField(cs, 'Версия ОС', _osVersionController), + _buildField(cs, 'Разрешение экрана', _screenController), + _buildField(cs, 'Архитектура', _architectureController), + _buildField(cs, 'ID устройства', _deviceIdController), + _buildField(cs, 'Часовой пояс', _timezoneController), + _buildField(cs, 'Локаль', _localeController), + _buildField(cs, 'Локаль устройства', _deviceLocaleController), + _buildField(cs, 'Версия приложения', _appVersionController), + _buildField(cs, 'Build Number', _buildNumberController), + _buildField(cs, 'Push Device Type', _pushDeviceTypeController), + _buildField(cs, 'MT Instance ID', _mtInstanceIdController), + _buildField(cs, 'Client Session ID', _clientSessionIdController, isLast: true), + ]), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), + child: _buildActionButtons(cs), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface, size: 24, weight: 400), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Text( + 'Подделка данных', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ], + ), + ); + } + + + + Widget _buildSection(ColorScheme cs, List children) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column(children: children), + ); + } + + Widget _buildField( + ColorScheme cs, + String label, + TextEditingController controller, { + bool isLast = false, + }) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + SizedBox( + width: 140, + child: Text( + label, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: controller, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + decoration: InputDecoration( + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + isDense: true, + ), + ), + ), + ], + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildActionButtons(ColorScheme cs) { + return Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: OutlinedButton( + onPressed: _randomizeAll, + style: OutlinedButton.styleFrom( + foregroundColor: cs.onSurface, + side: BorderSide(color: cs.outline, width: 1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Symbols.shuffle, size: 18, weight: 400), + const SizedBox(width: 6), + const Text( + 'Рандомизировать', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: FilledButton( + onPressed: _saveSettings, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: const Text( + 'Сохранить', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ); + } +} From b3af3c959f4419b27d40b3f7484bc7ebf720e2fa Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 5 Apr 2026 13:28:22 +0300 Subject: [PATCH 24/59] fix(ui): center folder tab labels in chat list --- lib/frontend/screens/chats/chat_list_screen.dart | 2 ++ lib/frontend/widgets/custom_notification.dart | 5 ++++- lib/main.dart | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 4ac2dc8..1f1bf57 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1581,6 +1581,7 @@ class _ChatListScreenState extends State } }, child: Container( + alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, @@ -1588,6 +1589,7 @@ class _ChatListScreenState extends State ), child: Text( title, + textAlign: TextAlign.center, style: TextStyle( color: isSelected ? cs.onPrimaryContainer : cs.primary, fontSize: 14, diff --git a/lib/frontend/widgets/custom_notification.dart b/lib/frontend/widgets/custom_notification.dart index 85c6e91..5bdb22b 100644 --- a/lib/frontend/widgets/custom_notification.dart +++ b/lib/frontend/widgets/custom_notification.dart @@ -2,7 +2,10 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; void showCustomNotification(BuildContext context, String message) { - final overlay = Overlay.of(context); + showCustomNotificationOnOverlay(Overlay.of(context), message); +} + +void showCustomNotificationOnOverlay(OverlayState overlay, String message) { final entry = OverlayEntry( builder: (context) => CustomNotification(message: message), ); diff --git a/lib/main.dart b/lib/main.dart index 979aec2..84fcf52 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -73,9 +73,9 @@ class KometAppState extends State { final navState = KometApp.navigatorKey.currentState; if (navState != null) { - final overlayContext = navState.overlay?.context; - if (overlayContext != null && overlayContext.mounted) { - showCustomNotification(overlayContext, e.message); + final overlay = navState.overlay; + if (overlay != null) { + showCustomNotificationOnOverlay(overlay, e.message); } await navState.pushAndRemoveUntil( From 503eac5d64e46d6c9ee61f53227ebd3f319eef45 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 5 Apr 2026 19:11:55 +0700 Subject: [PATCH 25/59] =?UTF-8?q?=D0=A4=D1=80=D0=BE=D0=BD=D1=82=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B5,=20=D0=BF=D0=BE=D0=BA=D0=B0=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82=D0=B5=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 17 +- lib/backend/modules/messages.dart | 12 +- lib/frontend/screens/chats/chat_screen.dart | 156 ++++- lib/frontend/widgets/message_bubble.dart | 725 ++++++++++++++++++++ lib/main.dart | 8 + 5 files changed, 884 insertions(+), 34 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 6a84839..402cf92 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -12,9 +12,9 @@ import '../core/transport/sender.dart'; import '../core/utils/logger.dart'; import 'package:device_info_plus/device_info_plus.dart'; -import 'dart:io'; -import 'package:timezone/data/latest_all.dart' as tz; import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:timezone/data/latest_all.dart' as tz; +import 'dart:io'; enum SessionState { disconnected, connecting, connected, online } @@ -239,6 +239,19 @@ class Api { _dispatcher.clearPending(); } + Future reconnectAndLogin() async { + await connect(); + if (_sessionState == SessionState.online && _onReconnectCallback != null) { + _onReconnectCallback!(); + } + } + + void Function()? _onReconnectCallback; + + void setReconnectCallback(void Function() callback) { + _onReconnectCallback = callback; + } + void _startPinging() { _pingTimer?.cancel(); _pingTimer = Timer.periodic(ServerConfig.pingInterval, (_) { diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 2cc90a7..7473e76 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -151,11 +151,13 @@ class MessagesModule { text: m['text'] as String?, time: (m['time'] as int?) ?? 0, status: m['status'] as String?, - payload: m - .cast< - String, - dynamic - >(), // Сохраняем весь пакет для гибкости (аттачи и т.д.) + payload: m.cast(), ); } + + Future sendMessage(int accountId, int chatId, String text) async { + final payload = {'chatId': chatId, 'text': text}; + + await _api.sendRequest(Opcode.msgSend, payload); + } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e7dfa6d..3a611ab 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -5,6 +5,7 @@ import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; +import '../../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { final int chatId; @@ -25,10 +26,13 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State with SingleTickerProviderStateMixin { final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); bool _hasText = false; bool _isLoading = true; + bool _isSending = false; late AnimationController _shimmerController; List _messages = []; + int _myId = 0; @override void initState() { @@ -44,16 +48,17 @@ class _ChatScreenState extends State Future _loadHistory() async { final activeProfile = await AppDatabase.loadActiveProfile(); - final myId = activeProfile?.id ?? 0; + _myId = activeProfile?.id ?? 0; final cachedRows = await AppDatabase.loadMessages( - myId, + _myId, widget.chatId, limit: 100, ); if (mounted && cachedRows.isNotEmpty) { setState(() { _messages = cachedRows.map((r) => CachedMessage.fromDbRow(r)).toList(); + _messages.sort((a, b) => a.time.compareTo(b.time)); if (api.state == SessionState.online) { _isLoading = false; } @@ -61,9 +66,9 @@ class _ChatScreenState extends State } try { - await messagesModule.fetchHistory(myId, widget.chatId); + await messagesModule.fetchHistory(_myId, widget.chatId); final updatedRows = await AppDatabase.loadMessages( - myId, + _myId, widget.chatId, limit: 100, ); @@ -72,6 +77,7 @@ class _ChatScreenState extends State _messages = updatedRows .map((r) => CachedMessage.fromDbRow(r)) .toList(); + _messages.sort((a, b) => a.time.compareTo(b.time)); _isLoading = false; }); } @@ -89,6 +95,7 @@ class _ChatScreenState extends State void dispose() { _messageController.removeListener(_onTextChanged); _messageController.dispose(); + _scrollController.dispose(); _shimmerController.dispose(); super.dispose(); } @@ -102,6 +109,73 @@ class _ChatScreenState extends State } } + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _myId == 0) return; + + setState(() { + _isSending = true; + }); + + try { + final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; + final now = DateTime.now().millisecondsSinceEpoch; + + final tempMessage = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'sending', + ); + + setState(() { + _messages.add(tempMessage); + _messageController.clear(); + _hasText = false; + }); + + _scrollToBottom(); + + await messagesModule.sendMessage(_myId, widget.chatId, text); + + final index = _messages.indexWhere((m) => m.id == tempId); + if (index != -1) { + setState(() { + _messages[index] = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'sent', + ); + }); + } + } catch (e) { + debugPrint('Error sending message: $e'); + } finally { + setState(() { + _isSending = false; + }); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -131,10 +205,7 @@ class _ChatScreenState extends State backgroundColor: cs.primaryContainer, child: Text( widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 12, - ), + style: TextStyle(color: cs.onPrimaryContainer, fontSize: 12), ), ), const SizedBox(width: 12), @@ -178,11 +249,9 @@ class _ChatScreenState extends State body: Column( children: [ Expanded( - child: - _isLoading || - (_messages.isEmpty && api.state != SessionState.online) + child: _isLoading && _messages.isEmpty ? _buildShimmerLoading() - : const SizedBox.shrink(), + : _buildMessagesList(), ), _buildInputArea(context), ], @@ -190,6 +259,44 @@ class _ChatScreenState extends State ); } + Widget _buildMessagesList() { + if (_messages.isEmpty) { + return Center( + child: Text( + 'No messages yet', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + return ListView.builder( + controller: _scrollController, + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[_messages.length - 1 - index]; + final isMe = message.senderId == _myId; + final prevMessage = index < _messages.length - 1 + ? _messages[_messages.length - 2 - index] + : null; + final nextMessage = index > 0 + ? _messages[_messages.length - index] + : null; + + return MessageBubble( + message: message, + isMe: isMe, + myId: _myId, + prevMessage: prevMessage, + nextMessage: nextMessage, + ); + }, + ); + } + Widget _buildShimmerLoading() { return AnimatedBuilder( animation: _shimmerController, @@ -316,20 +423,12 @@ class _ChatScreenState extends State child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon( - Symbols.face, - color: mutedIcon, - size: 24, - weight: 400, - ), + Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400), const SizedBox(width: 12), Expanded( child: TextField( controller: _messageController, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - ), + style: TextStyle(color: cs.onSurface, fontSize: 16), maxLines: null, keyboardType: TextInputType.multiline, textAlignVertical: TextAlignVertical.center, @@ -379,11 +478,14 @@ class _ChatScreenState extends State color: _hasText ? cs.primary : cs.surfaceContainerHighest, shape: BoxShape.circle, ), - child: Icon( - _hasText ? Symbols.send : Symbols.mic, - color: _hasText ? cs.onPrimary : cs.onSurface, - size: 24, - weight: 400, + child: GestureDetector( + onTap: _hasText ? _sendMessage : null, + child: Icon( + _hasText ? Symbols.send : Symbols.mic, + color: _hasText ? cs.onPrimary : cs.onSurface, + size: 24, + weight: 400, + ), ), ), ], diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index e69de29..208d29c 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -0,0 +1,725 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../backend/modules/messages.dart'; + +enum MessageType { text, attachment, voice } + +enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } + +class MessageBubble extends StatelessWidget { + final CachedMessage message; + final bool isMe; + final int myId; + final CachedMessage? prevMessage; + final CachedMessage? nextMessage; + + const MessageBubble({ + super.key, + required this.message, + required this.isMe, + required this.myId, + this.prevMessage, + this.nextMessage, + }); + + bool get isGroupedWithNext { + if (nextMessage == null) return false; + if (nextMessage!.senderId != message.senderId) return false; + final timeDiff = nextMessage!.time - message.time; + return timeDiff < 300000; + } + + BubbleShape get shape { + final hasPrevFromMe = prevMessage?.senderId == message.senderId; + final prevTimeDiff = hasPrevFromMe + ? message.time - prevMessage!.time + : 999999999; + + final hasNextFromMe = nextMessage?.senderId == message.senderId; + final nextTimeDiff = hasNextFromMe + ? nextMessage!.time - message.time + : 999999999; + + final bool groupedWithPrev = hasPrevFromMe && prevTimeDiff < 300000; + final bool groupedWithNext = hasNextFromMe && nextTimeDiff < 300000; + + if (!groupedWithPrev && !groupedWithNext) return BubbleShape.singleMiddle; + if (!groupedWithPrev && groupedWithNext) return BubbleShape.singleTop; + if (groupedWithPrev && !groupedWithNext) return BubbleShape.singleBottom; + + return BubbleShape.groupedMiddle; + } + + MessageType get contentType { + final payload = message.payload; + if (payload == null) return MessageType.text; + + final attachments = payload['attachments']; + if (attachments is List && attachments.isNotEmpty) + return MessageType.attachment; + + final voice = payload['voice']; + if (voice != null) return MessageType.voice; + + return MessageType.text; + } + + // скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие + BorderRadius get _borderRadius { + switch (shape) { + case BubbleShape.singleTop: + return BorderRadius.only( + topLeft: isMe ? Radius.circular(20) : Radius.circular(4), + topRight: isMe ? Radius.circular(4) : Radius.circular(20), + bottomLeft: isMe ? Radius.circular(4) : Radius.circular(4), + bottomRight: isMe ? Radius.circular(4) : Radius.circular(4), + ); + case BubbleShape.singleBottom: + return BorderRadius.only( + topLeft: isMe ? Radius.circular(4) : Radius.circular(4), + topRight: isMe ? Radius.circular(4) : Radius.circular(4), + bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), + bottomRight: isMe ? Radius.circular(4) : Radius.circular(20), + ); + case BubbleShape.singleMiddle: + return BorderRadius.only( + topLeft: isMe ? Radius.circular(20) : Radius.circular(20), + topRight: isMe ? Radius.circular(20) : Radius.circular(20), + bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), + bottomRight: isMe ? Radius.circular(4) : Radius.circular(20), + ); + case BubbleShape.groupedMiddle: + return BorderRadius.only( + topLeft: isMe ? Radius.circular(20) : Radius.circular(4), + topRight: isMe ? Radius.circular(4) : Radius.circular(4), + bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), + bottomRight: isMe ? Radius.circular(4) : Radius.circular(4), + ); + } + } + + // ВРОДЕ отступы между сообщениями, если они выглядят адекватно не трогайте пж я ради них кишку на шею намотал + // ОТСТУПЫ МЕЖДУ СООБЩЕНИЯМИ (top/bottom margin) + // Зависит от shape (группировка) И contentType (тип контента) + double get topMargin { + switch (contentType) { + case MessageType.text: + switch (shape) { + case BubbleShape.singleTop: + return 6; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.attachment: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 6; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.voice: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + } + return 4; + } + + double get bottomMargin { + switch (contentType) { + case MessageType.text: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.attachment: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + case MessageType.voice: + switch (shape) { + case BubbleShape.singleTop: + return 1; + case BubbleShape.singleBottom: + return 1; + case BubbleShape.singleMiddle: + return 4; + case BubbleShape.groupedMiddle: + return 1; + } + } + return 4; + } + + // Внутренний отступ, размеры типа я хз + EdgeInsets get padding { + switch (contentType) { + case MessageType.text: + switch (shape) { + case BubbleShape.groupedMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); + case BubbleShape.singleTop: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleBottom: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + } + case MessageType.attachment: + switch (shape) { + case BubbleShape.groupedMiddle: + return const EdgeInsets.all(0); + case BubbleShape.singleTop: + return const EdgeInsets.all(0); + case BubbleShape.singleBottom: + return const EdgeInsets.all(0); + case BubbleShape.singleMiddle: + return const EdgeInsets.all(0); + } + case MessageType.voice: + switch (shape) { + case BubbleShape.groupedMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 6); + case BubbleShape.singleTop: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleBottom: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + case BubbleShape.singleMiddle: + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + } + } + return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + + return Padding( + padding: EdgeInsets.only( + left: isMe ? 60 : 12, + right: isMe ? 12 : 60, + top: topMargin, + bottom: bottomMargin, + ), + child: Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE9E9EB)), + borderRadius: _borderRadius, + ), + padding: padding, + child: _buildContent(context), + ), + ), + ); + } + + Widget _buildContent(BuildContext context) { + switch (contentType) { + case MessageType.attachment: + return _buildAttachmentContent(context); + case MessageType.voice: + return _buildVoiceContent(context); + case MessageType.text: + default: + return _buildTextContent(context); + } + } + + Widget _buildTextContent(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + _formatTime(message.time), + style: TextStyle( + color: textColor.withValues(alpha: 0.7), + fontSize: 11, + ), + ), + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + ], + ); + } + + Widget _buildAttachmentContent(BuildContext context) { + final payload = message.payload; + final attachments = payload?['attachments'] as List?; + if (attachments == null || attachments.isEmpty) { + return _buildTextContent(context); + } + + final attachment = attachments.first as Map?; + if (attachment == null) return _buildTextContent(context); + + final type = attachment['type'] as String?; + final url = attachment['url'] ?? attachment['path']; + final thumbnail = attachment['thumbnail']; + + if (type == 'image' || type == 'photo') { + return _buildImageAttachment(context, url, thumbnail); + } else if (type == 'video') { + return _buildVideoAttachment(context, url, attachment['duration']); + } else if (type == 'file') { + return _buildFileAttachment(context, attachment['name'] ?? 'File', url); + } + + return _buildTextContent(context); + } + + Widget _buildImageAttachment( + BuildContext ctx, + dynamic url, + dynamic thumbnail, + ) { + final cs = Theme.of(ctx).colorScheme; + final imageUrl = url?.toString() ?? ''; + final thumbUrl = thumbnail?.toString() ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + if (thumbUrl.isNotEmpty) + Image.network( + thumbUrl, + width: 200, + height: 200, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs), + ) + else if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + width: 200, + height: 200, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs), + ) + else + _buildImagePlaceholder(cs), + if (imageUrl.isNotEmpty) + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () {}), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + _buildMeta(ctx), + ], + ); + } + + Widget _buildImagePlaceholder(ColorScheme cs) { + return Container( + width: 200, + height: 200, + color: cs.surfaceContainerHighest, + child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant), + ); + } + + Widget _buildVideoAttachment( + BuildContext ctx, + dynamic url, + dynamic duration, + ) { + final cs = Theme.of(ctx).colorScheme; + final videoUrl = url?.toString() ?? ''; + final durationSec = duration as int? ?? 0; + final durationStr = _formatDuration(durationSec); + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + Container( + width: 200, + height: 150, + color: cs.surfaceContainerHighest, + child: Icon( + Symbols.videocam, + size: 48, + color: cs.onSurfaceVariant, + ), + ), + Positioned( + bottom: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + durationStr, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + if (videoUrl.isNotEmpty) + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () {}), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + _buildMeta(ctx), + ], + ); + } + + Widget _buildFileAttachment(BuildContext ctx, String fileName, dynamic url) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + width: 220, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isMe + ? Colors.white.withValues(alpha: 0.15) + : (isDark ? cs.surface : const Color(0xFFFFFFFF)), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isMe + ? Colors.white.withValues(alpha: 0.3) + : cs.outlineVariant.withValues(alpha: 0.5), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isMe + ? Colors.white.withValues(alpha: 0.2) + : cs.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Symbols.description, + color: isMe ? Colors.white : cs.primary, + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + 'Tap to download', + style: TextStyle( + color: textColor.withValues(alpha: 0.6), + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 6), + _buildMeta(ctx), + ], + ); + } + + Widget _buildVoiceContent(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final payload = message.payload; + final voice = payload?['voice'] as Map?; + final duration = voice?['duration'] as int? ?? 0; + final url = voice?['url']?.toString() ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _VoiceMessageBubble( + duration: duration, + url: url, + textColor: textColor, + isMe: isMe, + ), + const SizedBox(height: 6), + _buildMeta(context), + ], + ); + } + + Widget _buildMeta(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final timeColor = isMe ? Colors.white70 : cs.onSurfaceVariant; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatTime(message.time), + style: TextStyle(color: timeColor, fontSize: 11), + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + ], + ), + ); + } + + Widget _buildStatusIcon(BuildContext context) { + final status = message.status; + IconData icon; + Color color; + + if (status == null || status == 'sending' || status == 'pending') { + icon = Symbols.check; + color = Colors.white70; + } else { + switch (status) { + case 'sent': + icon = Symbols.check; + color = Colors.white70; + case 'delivered': + icon = Symbols.done_all; + color = Colors.white70; + case 'read': + icon = Symbols.done_all; + color = const Color(0xFF34C759); + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = Colors.white70; + } + } + + return Icon(icon, size: 14, color: color); + } + + String _formatTime(int timestamp) { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + String _formatDuration(int seconds) { + final min = seconds ~/ 60; + final sec = seconds % 60; + return '$min:${sec.toString().padLeft(2, '0')}'; + } +} + +class _VoiceMessageBubble extends StatefulWidget { + final int duration; + final String url; + final Color textColor; + final bool isMe; + + const _VoiceMessageBubble({ + required this.duration, + required this.url, + required this.textColor, + required this.isMe, + }); + + @override + State<_VoiceMessageBubble> createState() => _VoiceMessageBubbleState(); +} + +class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { + bool _isPlaying = false; + double _progress = 0.0; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + + return Container( + width: 220, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + GestureDetector( + onTap: _togglePlay, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.2) + : cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + _isPlaying ? Symbols.pause : Symbols.play_arrow, + color: widget.isMe ? Colors.white : cs.primary, + size: 20, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Container( + height: 24, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFD1D1D6)), + borderRadius: BorderRadius.circular(2), + ), + ), + FractionallySizedBox( + widthFactor: _progress.clamp(0.0, 1.0), + child: Container( + height: 24, + decoration: BoxDecoration( + color: widget.isMe + ? Colors.white.withValues(alpha: 0.5) + : cs.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + SizedBox( + height: 24, + child: Center( + child: Text( + _formatDuration(widget.duration), + style: TextStyle( + color: widget.textColor.withValues(alpha: 0.8), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + void _togglePlay() { + setState(() { + _isPlaying = !_isPlaying; + }); + } + + String _formatDuration(int seconds) { + final min = seconds ~/ 60; + final sec = seconds % 60; + return '$min:${sec.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/main.dart b/lib/main.dart index 84fcf52..691248e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -62,6 +62,14 @@ class KometAppState extends State { void initState() { super.initState(); _locale = widget.initialLocale; + + api.setReconnectCallback(() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await accountModule.login(accountId: accountId); + } + }); + api.sessionExpiredStream.listen((SessionExpiredException e) async { if (_isLoggingOut) return; _isLoggingOut = true; From 48ae6ff4771f57ea9d545e17c69cf4bdd29c6d0c Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 5 Apr 2026 21:13:38 +0700 Subject: [PATCH 26/59] =?UTF-8?q?=D0=B5=D0=B1=D0=B0=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=20=D0=BE=D0=BD=D0=BE=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8F=20=D0=B7=D0=B0=D0=B5=D0=B1=D0=B0=D0=BB=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 139 +++++ lib/frontend/widgets/message_bubble.dart | 614 ++++++++++++++++++----- lib/models/attachment.dart | 395 +++++++++++++++ 3 files changed, 1014 insertions(+), 134 deletions(-) create mode 100644 lib/models/attachment.dart diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 7473e76..483cd94 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'dart:typed_data'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; +import '../../models/attachment.dart'; class CachedMessage { final String id; @@ -12,6 +14,7 @@ class CachedMessage { final int time; final String? status; final Map? payload; + final List? attachments; const CachedMessage({ required this.id, @@ -22,6 +25,7 @@ class CachedMessage { required this.time, this.status, this.payload, + this.attachments, }); factory CachedMessage.fromDbRow(Map row) { @@ -33,6 +37,16 @@ class CachedMessage { } catch (_) {} } + List? attachments; + if (payload != null) { + final attaches = payload['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .map((a) => MessageAttachment.fromMap(a as Map)) + .toList(); + } + } + return CachedMessage( id: row['id'] as String, accountId: row['account_id'] as int, @@ -42,6 +56,7 @@ class CachedMessage { time: row['time'] as int, status: row['status'] as String?, payload: payload, + attachments: attachments, ); } @@ -143,6 +158,15 @@ class MessagesModule { final id = m['id']?.toString(); if (id == null) return null; + final attaches = m['attaches'] as List?; + List? attachments; + if (attaches != null) { + attachments = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(a.cast())) + .toList(); + } + return CachedMessage( id: id, accountId: accountId, @@ -152,6 +176,7 @@ class MessagesModule { time: (m['time'] as int?) ?? 0, status: m['status'] as String?, payload: m.cast(), + attachments: attachments, ); } @@ -160,4 +185,118 @@ class MessagesModule { await _api.sendRequest(Opcode.msgSend, payload); } + + Future downloadPhoto(String baseUrl, String photoToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': photoToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final content = data['content']; + if (content is String) { + return Uri.parse(content).host.isNotEmpty ? null : null; + } + return null; + } catch (e) { + return null; + } + } + + Future getPhotoUrl(String baseUrl, String photoToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': photoToken, + }); + + 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 downloadVideo(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; + + final content = data['content']; + if (content is String) { + return Uri.parse(content).host.isNotEmpty ? null : null; + } + return null; + } catch (e) { + return null; + } + } + + 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, { + 'url': baseUrl, + 'token': fileToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final content = data['content']; + if (content is String) { + return Uri.parse(content).host.isNotEmpty ? null : null; + } + return null; + } catch (e) { + return null; + } + } + + Future getFileUrl(String baseUrl, String fileToken) async { + try { + final response = await _api.sendRequest(Opcode.fileDownload, { + 'url': baseUrl, + 'token': fileToken, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + return data['content'] as String?; + } catch (e) { + return null; + } + } } diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 208d29c..ba7b4a1 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,12 +1,40 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; +import '../../models/attachment.dart'; enum MessageType { text, attachment, voice } enum BubbleShape { singleTop, singleBottom, singleMiddle, groupedMiddle } class MessageBubble extends StatelessWidget { + static const double photoMaxSize = 280.0; + static const double photoMinSize = 100.0; + static const double photoBorderRadius = 12.0; + static const double bubbleBorderRadius = 20.0; + static const double captionPaddingHorizontal = 6.0; + static const double captionPaddingRight = 4.0; + static const double compactTimePadding = 8.0; + + bool get _hasPhotoWithCaption { + if (message.attachments == null || message.attachments!.isEmpty) + return false; + final hasPhoto = message.attachments!.any((a) => a is PhotoAttachment); + final hasCaption = message.text != null && message.text!.isNotEmpty; + debugPrint( + 'DEBUG _hasPhotoWithCaption: hasPhoto=$hasPhoto, hasCaption=$hasCaption, text="${message.text}", shape=$shape', + ); + return hasPhoto && hasCaption; + } + + bool get _hasMultiplePhotosNoCaption { + if (message.attachments == null || message.attachments!.isEmpty) + return false; + final photoCount = message.attachments!.whereType().length; + final hasCaption = message.text != null && message.text!.isNotEmpty; + return photoCount >= 2 && !hasCaption; + } + final CachedMessage message; final bool isMe; final int myId; @@ -51,13 +79,15 @@ class MessageBubble extends StatelessWidget { } MessageType get contentType { + if (message.attachments != null && message.attachments!.isNotEmpty) { + final first = message.attachments!.first; + if (first.type == AttachmentType.audio) return MessageType.voice; + return MessageType.attachment; + } + final payload = message.payload; if (payload == null) return MessageType.text; - final attachments = payload['attachments']; - if (attachments is List && attachments.isNotEmpty) - return MessageType.attachment; - final voice = payload['voice']; if (voice != null) return MessageType.voice; @@ -66,34 +96,61 @@ class MessageBubble extends StatelessWidget { // скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие BorderRadius get _borderRadius { + final topRadius = Radius.circular(bubbleBorderRadius); + final bottomRadius = Radius.circular(bubbleBorderRadius); + final smallRadius = const Radius.circular(4); + + if (_hasPhotoWithCaption && + (shape == BubbleShape.singleTop || + shape == BubbleShape.singleMiddle || + shape == BubbleShape.singleBottom)) { + return BorderRadius.only( + topLeft: topRadius, + topRight: isMe ? topRadius : topRadius, + bottomLeft: smallRadius, + bottomRight: smallRadius, + ); + } + + if (_hasMultiplePhotosNoCaption && + (shape == BubbleShape.singleBottom || + shape == BubbleShape.singleMiddle)) { + return BorderRadius.only( + topLeft: smallRadius, + topRight: smallRadius, + bottomLeft: isMe ? smallRadius : smallRadius, + bottomRight: isMe ? smallRadius : bottomRadius, + ); + } + switch (shape) { case BubbleShape.singleTop: return BorderRadius.only( - topLeft: isMe ? Radius.circular(20) : Radius.circular(4), - topRight: isMe ? Radius.circular(4) : Radius.circular(20), - bottomLeft: isMe ? Radius.circular(4) : Radius.circular(4), - bottomRight: isMe ? Radius.circular(4) : Radius.circular(4), + topLeft: isMe ? topRadius : smallRadius, + topRight: isMe ? smallRadius : topRadius, + bottomLeft: smallRadius, + bottomRight: smallRadius, ); case BubbleShape.singleBottom: return BorderRadius.only( - topLeft: isMe ? Radius.circular(4) : Radius.circular(4), - topRight: isMe ? Radius.circular(4) : Radius.circular(4), - bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), - bottomRight: isMe ? Radius.circular(4) : Radius.circular(20), + topLeft: smallRadius, + topRight: smallRadius, + bottomLeft: isMe ? topRadius : smallRadius, + bottomRight: isMe ? smallRadius : topRadius, ); case BubbleShape.singleMiddle: return BorderRadius.only( - topLeft: isMe ? Radius.circular(20) : Radius.circular(20), - topRight: isMe ? Radius.circular(20) : Radius.circular(20), - bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), - bottomRight: isMe ? Radius.circular(4) : Radius.circular(20), + topLeft: topRadius, + topRight: topRadius, + bottomLeft: isMe ? topRadius : smallRadius, + bottomRight: isMe ? smallRadius : topRadius, ); case BubbleShape.groupedMiddle: return BorderRadius.only( - topLeft: isMe ? Radius.circular(20) : Radius.circular(4), - topRight: isMe ? Radius.circular(4) : Radius.circular(4), - bottomLeft: isMe ? Radius.circular(20) : Radius.circular(4), - bottomRight: isMe ? Radius.circular(4) : Radius.circular(4), + topLeft: isMe ? topRadius : smallRadius, + topRight: isMe ? smallRadius : smallRadius, + bottomLeft: isMe ? topRadius : smallRadius, + bottomRight: isMe ? smallRadius : smallRadius, ); } } @@ -288,7 +345,7 @@ class MessageBubble extends StatelessWidget { _formatTime(message.time), style: TextStyle( color: textColor.withValues(alpha: 0.7), - fontSize: 11, + fontSize: 10, ), ), ), @@ -298,157 +355,393 @@ class MessageBubble extends StatelessWidget { } Widget _buildAttachmentContent(BuildContext context) { - final payload = message.payload; - final attachments = payload?['attachments'] as List?; + final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { return _buildTextContent(context); } - final attachment = attachments.first as Map?; - if (attachment == null) return _buildTextContent(context); - - final type = attachment['type'] as String?; - final url = attachment['url'] ?? attachment['path']; - final thumbnail = attachment['thumbnail']; - - if (type == 'image' || type == 'photo') { - return _buildImageAttachment(context, url, thumbnail); - } else if (type == 'video') { - return _buildVideoAttachment(context, url, attachment['duration']); - } else if (type == 'file') { - return _buildFileAttachment(context, attachment['name'] ?? 'File', url); + final photos = attachments.whereType().toList(); + if (photos.isEmpty) { + return _buildGenericAttachment(context, attachments.first); } - return _buildTextContent(context); + return _buildPhotoContent(context, photos); } - Widget _buildImageAttachment( - BuildContext ctx, - dynamic url, - dynamic thumbnail, - ) { - final cs = Theme.of(ctx).colorScheme; - final imageUrl = url?.toString() ?? ''; - final thumbUrl = thumbnail?.toString() ?? ''; + Widget _buildPhotoContent(BuildContext ctx, List photos) { + final hasCaption = message.text != null && message.text!.isNotEmpty; + final count = photos.length; + + Widget photosWidget; + if (count == 1) { + photosWidget = _buildSinglePhoto(ctx, photos[0]); + } else if (count == 2) { + photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]); + } else { + photosWidget = _buildPhotoGrid(ctx, photos); + } + + if (!hasCaption) { + return Stack( + children: [ + photosWidget, + Positioned( + bottom: compactTimePadding, + right: compactTimePadding, + child: _buildCompactTime(ctx), + ), + ], + ); + } + + if (count == 1) { + return IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + photosWidget, + Padding( + padding: const EdgeInsets.only( + left: captionPaddingHorizontal, + right: captionPaddingRight, + bottom: 6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _buildCaption(ctx)), + _buildMeta(ctx), + ], + ), + ), + ], + ), + ); + } return Column( - crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( + photosWidget, + Padding( + padding: const EdgeInsets.only( + left: captionPaddingHorizontal, + right: captionPaddingRight, + bottom: 6, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (thumbUrl.isNotEmpty) - Image.network( - thumbUrl, - width: 200, - height: 200, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs), - ) - else if (imageUrl.isNotEmpty) - Image.network( - imageUrl, - width: 200, - height: 200, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildImagePlaceholder(cs), - ) - else - _buildImagePlaceholder(cs), - if (imageUrl.isNotEmpty) - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell(onTap: () {}), - ), - ), + Expanded(child: _buildCaption(ctx)), + _buildMeta(ctx), ], ), ), - const SizedBox(height: 6), - _buildMeta(ctx), ], ); } - Widget _buildImagePlaceholder(ColorScheme cs) { - return Container( - width: 200, - height: 200, - color: cs.surfaceContainerHighest, - child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant), + Widget _buildSinglePhoto(BuildContext ctx, PhotoAttachment photo) { + final imageUrl = photo.baseUrl ?? ''; + final width = photo.width?.toDouble() ?? 200; + final height = photo.height?.toDouble() ?? 200; + + final constrainedWidth = width.clamp(photoMinSize, photoMaxSize); + final constrainedHeight = height.clamp(photoMinSize, photoMaxSize); + + final bool matchTop = _hasPhotoWithCaption; + final bool matchBottom = !_hasPhotoWithCaption; + + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + topRight: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + bottomLeft: Radius.circular( + matchBottom ? (isMe ? bubbleBorderRadius : 4) : 4, + ), + bottomRight: Radius.circular( + matchBottom ? (isMe ? 4 : bubbleBorderRadius) : 4, + ), + ), + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + width: constrainedWidth, + height: constrainedHeight, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _buildPhotoPlaceholder( + ctx, + constrainedWidth, + constrainedHeight, + ), + ) + else + _buildPhotoPlaceholder(ctx, constrainedWidth, constrainedHeight), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)), + ), + ), + ], + ), ); } - Widget _buildVideoAttachment( + Widget _buildTwoPhotos( BuildContext ctx, - dynamic url, - dynamic duration, + PhotoAttachment p1, + PhotoAttachment p2, ) { - final cs = Theme.of(ctx).colorScheme; - final videoUrl = url?.toString() ?? ''; - final durationSec = duration as int? ?? 0; - final durationStr = _formatDuration(durationSec); + final matchTop = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop; + final matchBottom = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - Container( - width: 200, - height: 150, - color: cs.surfaceContainerHighest, - child: Icon( - Symbols.videocam, - size: 48, - color: cs.onSurfaceVariant, - ), + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + topRight: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius), + bottomRight: Radius.circular( + matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildPhotoTile(ctx, p1), + const SizedBox(width: 2), + _buildPhotoTile(ctx, p2), + ], + ), + ); + } + + Widget _buildPhotoGrid(BuildContext ctx, List photos) { + final displayCount = photos.length > 4 ? 4 : photos.length; + final remaining = photos.length - 4; + + final matchTop = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleTop; + final matchBottom = + _hasMultiplePhotosNoCaption && shape == BubbleShape.singleBottom; + + return ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + topRight: Radius.circular( + matchTop ? bubbleBorderRadius : photoBorderRadius, + ), + bottomLeft: Radius.circular(matchBottom ? 4 : photoBorderRadius), + bottomRight: Radius.circular( + matchBottom ? (isMe ? 4 : bubbleBorderRadius) : photoBorderRadius, + ), + ), + child: GridView.count( + crossAxisCount: 2, + mainAxisSpacing: 2, + crossAxisSpacing: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: List.generate(displayCount, (i) { + if (i == 3 && remaining > 0) { + return _buildPhotoTileWithOverlay(ctx, photos[i], '+$remaining'); + } + return _buildPhotoTile(ctx, photos[i]); + }), + ), + ); + } + + Widget _buildPhotoTile(BuildContext ctx, PhotoAttachment photo) { + final imageUrl = photo.baseUrl ?? ''; + return Expanded( + child: AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, __, ___) => + _buildPhotoPlaceholder(ctx, 100, 100), + ) + else + _buildPhotoPlaceholder(ctx, 100, 100), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () => _openPhotoViewer(ctx, photo)), ), - Positioned( - bottom: 8, - left: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(4), - ), + ), + ], + ), + ), + ); + } + + Widget _buildPhotoTileWithOverlay( + BuildContext ctx, + PhotoAttachment photo, + String overlay, + ) { + final imageUrl = photo.baseUrl ?? ''; + return Expanded( + child: AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + if (imageUrl.isNotEmpty) + Image.network( + imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, __, ___) => + _buildPhotoPlaceholder(ctx, 100, 100), + ) + else + _buildPhotoPlaceholder(ctx, 100, 100), + Positioned.fill( + child: Container( + color: Colors.black45, + child: Center( child: Text( - durationStr, - style: const TextStyle(color: Colors.white, fontSize: 12), + overlay, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), ), ), ), - if (videoUrl.isNotEmpty) - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell(onTap: () {}), - ), - ), - ], - ), + ), + ], ), - const SizedBox(height: 6), - _buildMeta(ctx), - ], + ), ); } - Widget _buildFileAttachment(BuildContext ctx, String fileName, dynamic url) { + Widget _buildPhotoPlaceholder( + BuildContext ctx, + double w, + double h, { + VoidCallback? onRetry, + }) { + final cs = Theme.of(ctx).colorScheme; + return Container( + width: w, + height: h, + color: cs.surfaceContainerHighest, + child: onRetry != null + ? Center( + child: IconButton( + icon: Icon(Symbols.refresh, color: cs.onSurfaceVariant), + onPressed: onRetry, + tooltip: 'Retry', + ), + ) + : Center( + child: Icon(Symbols.image, size: 48, color: cs.onSurfaceVariant), + ), + ); + } + + Widget _buildCaption(BuildContext ctx) { final cs = Theme.of(ctx).colorScheme; final isDark = cs.brightness == Brightness.dark; final textColor = isMe ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + return Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ); + } + + Widget _buildGenericAttachment( + BuildContext ctx, + MessageAttachment attachment, + ) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + + switch (attachment.type) { + case AttachmentType.video: + return _buildVideoAttachment(ctx, attachment); + case AttachmentType.file: + return _buildFileAttachment(ctx, attachment); + case AttachmentType.sticker: + return _buildStickerAttachment(ctx, attachment); + default: + return _buildTextContent(ctx); + } + } + + Widget _buildVideoAttachment(BuildContext ctx, MessageAttachment video) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(photoBorderRadius), + child: Stack( + children: [ + Container( + width: 200, + height: 150, + color: Theme.of(ctx).colorScheme.surfaceContainerHighest, + child: Icon( + Symbols.videocam, + size: 48, + color: Theme.of(ctx).colorScheme.onSurfaceVariant, + ), + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell(onTap: () {}), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + _buildMeta(ctx), + ], + ); + } + + Widget _buildFileAttachment(BuildContext ctx, MessageAttachment file) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final name = (file as dynamic).name as String? ?? 'File'; + return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -459,7 +752,7 @@ class MessageBubble extends StatelessWidget { color: isMe ? Colors.white.withValues(alpha: 0.15) : (isDark ? cs.surface : const Color(0xFFFFFFFF)), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(photoBorderRadius), border: Border.all( color: isMe ? Colors.white.withValues(alpha: 0.3) @@ -489,7 +782,7 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - fileName, + name, style: TextStyle( color: textColor, fontSize: 14, @@ -518,6 +811,33 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) { + final preview = (sticker as dynamic).previewData as String? ?? ''; + + return ClipRRect( + borderRadius: BorderRadius.circular(photoBorderRadius), + child: Stack( + children: [ + if (preview.isNotEmpty) + Image.network( + preview, + width: 150, + height: 150, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + _buildPhotoPlaceholder(ctx, 150, 150), + ) + else + _buildPhotoPlaceholder(ctx, 150, 150), + ], + ), + ); + } + + void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) { + // TODO: Open photo viewer + } + Widget _buildVoiceContent(BuildContext context) { final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; @@ -553,6 +873,7 @@ class MessageBubble extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, children: [ Text( _formatTime(message.time), @@ -564,6 +885,31 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildCompactTime(BuildContext ctx) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final bgColor = isMe + ? Colors.black.withValues(alpha: 0.4) + : Colors.black.withValues(alpha: 0.5); + final textColor = Colors.white; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatTime(message.time), + style: TextStyle( + color: textColor, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ); + } + Widget _buildStatusIcon(BuildContext context) { final status = message.status; IconData icon; diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart new file mode 100644 index 0000000..2270210 --- /dev/null +++ b/lib/models/attachment.dart @@ -0,0 +1,395 @@ +enum AttachmentType { + photo, + video, + audio, + file, + contact, + location, + sticker, + control, +} + +abstract class MessageAttachment { + final AttachmentType type; + final String? previewData; + final String? baseUrl; + final String? fileUrl; + + const MessageAttachment({ + required this.type, + this.previewData, + this.baseUrl, + this.fileUrl, + }); + + factory MessageAttachment.fromMap(Map map) { + final type = (map['_type'] as String? ?? '').toUpperCase(); + switch (type) { + case 'PHOTO': + return PhotoAttachment.fromMap(map); + case 'VIDEO': + return VideoAttachment.fromMap(map); + case 'AUDIO': + return AudioAttachment.fromMap(map); + case 'FILE': + return FileAttachment.fromMap(map); + case 'STICKER': + return StickerAttachment.fromMap(map); + case 'CONTACT': + return ContactAttachment.fromMap(map); + case 'LOCATION': + return LocationAttachment.fromMap(map); + case 'CONTROL': + return ControlAttachment.fromMap(map); + default: + return UnknownAttachment(map); + } + } + + Map toMap(); +} + +class PhotoAttachment extends MessageAttachment { + final int? photoId; + final String? photoToken; + final int? width; + final int? height; + final int? size; + + const PhotoAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.photoId, + this.photoToken, + this.width, + this.height, + this.size, + }) : super(type: AttachmentType.photo); + + factory PhotoAttachment.fromMap(Map map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.from(previewRaw); + final base64 = String.fromCharCodes(bytes); + previewStr = 'data:image/webp;base64,$base64'; + } catch (_) {} + } + + return PhotoAttachment( + previewData: previewStr, + baseUrl: map['baseUrl'] as String?, + photoId: map['photoId'] as int?, + photoToken: map['photoToken'] as String?, + width: map['width'] as int?, + height: map['height'] as int?, + size: map['size'] as int?, + ); + } + + @override + Map toMap() => { + '_type': 'PHOTO', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'photoId': photoId, + 'photoToken': photoToken, + 'width': width, + 'height': height, + 'size': size, + }; +} + +class VideoAttachment extends MessageAttachment { + final int? videoId; + final String? videoToken; + final int? width; + final int? height; + final int? duration; + final int? size; + + const VideoAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.videoId, + this.videoToken, + this.width, + this.height, + this.duration, + this.size, + }) : super(type: AttachmentType.video); + + factory VideoAttachment.fromMap(Map map) { + return VideoAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + videoId: map['videoId'] as int?, + videoToken: map['videoToken'] as String?, + width: map['width'] as int?, + height: map['height'] as int?, + duration: map['duration'] as int?, + size: map['size'] as int?, + ); + } + + @override + Map toMap() => { + '_type': 'VIDEO', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'videoId': videoId, + 'videoToken': videoToken, + 'width': width, + 'height': height, + 'duration': duration, + 'size': size, + }; +} + +class AudioAttachment extends MessageAttachment { + final int? audioId; + final String? audioToken; + final int? duration; + final int? size; + final String? waveform; + + const AudioAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.audioId, + this.audioToken, + this.duration, + this.size, + this.waveform, + }) : super(type: AttachmentType.audio); + + factory AudioAttachment.fromMap(Map map) { + return AudioAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + audioId: map['audioId'] as int?, + audioToken: map['audioToken'] as String?, + duration: map['duration'] as int?, + size: map['size'] as int?, + waveform: map['waveform'] as String?, + ); + } + + @override + Map toMap() => { + '_type': 'AUDIO', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'audioId': audioId, + 'audioToken': audioToken, + 'duration': duration, + 'size': size, + 'waveform': waveform, + }; +} + +class FileAttachment extends MessageAttachment { + final int? fileId; + final String? fileToken; + final String? name; + final int? size; + + const FileAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.fileId, + this.fileToken, + this.name, + this.size, + }) : super(type: AttachmentType.file); + + factory FileAttachment.fromMap(Map map) { + return FileAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + fileId: map['fileId'] as int?, + fileToken: map['fileToken'] as String?, + name: map['name'] as String?, + size: map['size'] as int?, + ); + } + + @override + Map toMap() => { + '_type': 'FILE', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'fileId': fileId, + 'fileToken': fileToken, + 'name': name, + 'size': size, + }; +} + +class StickerAttachment extends MessageAttachment { + final String? stickerId; + final String? stickerPackId; + final int? width; + final int? height; + + const StickerAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.stickerId, + this.stickerPackId, + this.width, + this.height, + }) : super(type: AttachmentType.sticker); + + factory StickerAttachment.fromMap(Map map) { + return StickerAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + stickerId: map['stickerId'] as String?, + stickerPackId: map['stickerPackId'] as String?, + width: map['width'] as int?, + height: map['height'] as int?, + ); + } + + @override + Map toMap() => { + '_type': 'STICKER', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'stickerId': stickerId, + 'stickerPackId': stickerPackId, + 'width': width, + 'height': height, + }; +} + +class ContactAttachment extends MessageAttachment { + final String? userId; + final String? firstName; + final String? lastName; + final String? phoneNumber; + + const ContactAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.userId, + this.firstName, + this.lastName, + this.phoneNumber, + }) : super(type: AttachmentType.contact); + + factory ContactAttachment.fromMap(Map map) { + return ContactAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + userId: map['userId'] as String?, + firstName: map['firstName'] as String?, + lastName: map['lastName'] as String?, + phoneNumber: map['phoneNumber'] as String?, + ); + } + + @override + Map toMap() => { + '_type': 'CONTACT', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'userId': userId, + 'firstName': firstName, + 'lastName': lastName, + 'phoneNumber': phoneNumber, + }; +} + +class LocationAttachment extends MessageAttachment { + final double? latitude; + final double? longitude; + final String? title; + final String? address; + + const LocationAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.latitude, + this.longitude, + this.title, + this.address, + }) : super(type: AttachmentType.location); + + factory LocationAttachment.fromMap(Map map) { + return LocationAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + latitude: (map['latitude'] as num?)?.toDouble(), + longitude: (map['longitude'] as num?)?.toDouble(), + title: map['title'] as String?, + address: map['address'] as String?, + ); + } + + @override + Map toMap() => { + '_type': 'LOCATION', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'latitude': latitude, + 'longitude': longitude, + 'title': title, + 'address': address, + }; +} + +class ControlAttachment extends MessageAttachment { + final String? event; + final String? title; + final List? userIds; + + const ControlAttachment({ + super.previewData, + super.baseUrl, + super.fileUrl, + this.event, + this.title, + this.userIds, + }) : super(type: AttachmentType.control); + + factory ControlAttachment.fromMap(Map map) { + return ControlAttachment( + previewData: map['previewData'] as String?, + baseUrl: map['baseUrl'] as String?, + event: map['event'] as String?, + title: map['title'] as String?, + userIds: (map['userIds'] as List?)?.cast(), + ); + } + + @override + Map toMap() => { + '_type': 'CONTROL', + 'previewData': previewData, + 'baseUrl': baseUrl, + 'event': event, + 'title': title, + 'userIds': userIds, + }; +} + +class UnknownAttachment extends MessageAttachment { + final Map rawData; + + const UnknownAttachment(this.rawData) : super(type: AttachmentType.photo); + + @override + Map toMap() => rawData; +} From b1a5bdca89bed178e2c2f5af5de663e75cd1a8a7 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 5 Apr 2026 21:37:15 +0700 Subject: [PATCH 27/59] =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=BE=D0=BC=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B4=D0=BE=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/message_bubble.dart | 40 +++++++++++++----------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index ba7b4a1..e6b213a 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -737,12 +737,18 @@ class MessageBubble extends StatelessWidget { Widget _buildFileAttachment(BuildContext ctx, MessageAttachment file) { final cs = Theme.of(ctx).colorScheme; final isDark = cs.brightness == Brightness.dark; + final name = (file as dynamic).name as String? ?? 'File'; + final size = (file as dynamic).size as int? ?? 0; + final sizeStr = _formatFileSize(size); final textColor = isMe ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); - final name = (file as dynamic).name as String? ?? 'File'; + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); - return Column( + return Row( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Container( @@ -752,12 +758,7 @@ class MessageBubble extends StatelessWidget { color: isMe ? Colors.white.withValues(alpha: 0.15) : (isDark ? cs.surface : const Color(0xFFFFFFFF)), - borderRadius: BorderRadius.circular(photoBorderRadius), - border: Border.all( - color: isMe - ? Colors.white.withValues(alpha: 0.3) - : cs.outlineVariant.withValues(alpha: 0.5), - ), + borderRadius: BorderRadius.circular(12), ), child: Row( children: [ @@ -771,15 +772,16 @@ class MessageBubble extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: Icon( - Symbols.description, + Symbols.file_download, color: isMe ? Colors.white : cs.primary, - size: 20, + size: 22, ), ), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Text( name, @@ -788,16 +790,13 @@ class MessageBubble extends StatelessWidget { fontSize: 14, fontWeight: FontWeight.w500, ), - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( - 'Tap to download', - style: TextStyle( - color: textColor.withValues(alpha: 0.6), - fontSize: 12, - ), + 'Скачать • $sizeStr', + style: TextStyle(color: subtitleColor, fontSize: 12), ), ], ), @@ -805,12 +804,17 @@ class MessageBubble extends StatelessWidget { ], ), ), - const SizedBox(height: 6), _buildMeta(ctx), ], ); } + String _formatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} МБ'; + } + Widget _buildStickerAttachment(BuildContext ctx, MessageAttachment sticker) { final preview = (sticker as dynamic).previewData as String? ?? ''; From 06413eaf0ee847526c3e767769a19762bf48323f Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 5 Apr 2026 23:39:04 +0700 Subject: [PATCH 28/59] =?UTF-8?q?=D1=8F=20=D1=83=D0=BC=D0=B5=D1=80=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D0=B0=20=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D0=BB?= =?UTF-8?q?=20=D1=84=D1=80=D0=BE=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/message_bubble.dart | 150 ++++++++++++----------- lib/models/attachment.dart | 56 ++++++++- 2 files changed, 131 insertions(+), 75 deletions(-) diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index e6b213a..c3a5b62 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -21,9 +21,6 @@ class MessageBubble extends StatelessWidget { return false; final hasPhoto = message.attachments!.any((a) => a is PhotoAttachment); final hasCaption = message.text != null && message.text!.isNotEmpty; - debugPrint( - 'DEBUG _hasPhotoWithCaption: hasPhoto=$hasPhoto, hasCaption=$hasCaption, text="${message.text}", shape=$shape', - ); return hasPhoto && hasCaption; } @@ -81,6 +78,7 @@ class MessageBubble extends StatelessWidget { MessageType get contentType { if (message.attachments != null && message.attachments!.isNotEmpty) { final first = message.attachments!.first; + if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; return MessageType.attachment; } @@ -251,16 +249,7 @@ class MessageBubble extends StatelessWidget { return const EdgeInsets.symmetric(horizontal: 14, vertical: 10); } case MessageType.attachment: - switch (shape) { - case BubbleShape.groupedMiddle: - return const EdgeInsets.all(0); - case BubbleShape.singleTop: - return const EdgeInsets.all(0); - case BubbleShape.singleBottom: - return const EdgeInsets.all(0); - case BubbleShape.singleMiddle: - return const EdgeInsets.all(0); - } + return const EdgeInsets.all(0); case MessageType.voice: switch (shape) { case BubbleShape.groupedMiddle: @@ -395,7 +384,12 @@ class MessageBubble extends StatelessWidget { } if (count == 1) { - return IntrinsicWidth( + final photo = photos[0]; + final pw = photo.width?.toDouble() ?? 200; + final photoWidth = pw.clamp(photoMinSize, photoMaxSize); + + return SizedBox( + width: photoWidth, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -410,7 +404,7 @@ class MessageBubble extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded(child: _buildCaption(ctx)), + Flexible(child: _buildCaption(ctx)), _buildMeta(ctx), ], ), @@ -744,68 +738,82 @@ class MessageBubble extends StatelessWidget { ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); final subtitleColor = isMe - ? Colors.white.withValues(alpha: 0.7) + ? Colors.white.withValues(alpha: 0.65) : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Container( - width: 220, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isMe - ? Colors.white.withValues(alpha: 0.15) - : (isDark ? cs.surface : const Color(0xFFFFFFFF)), - borderRadius: BorderRadius.circular(12), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: isMe + ? Colors.white.withValues(alpha: 0.2) + : cs.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Symbols.description, + color: isMe ? Colors.white : cs.primary, + size: 20, + ), ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: isMe - ? Colors.white.withValues(alpha: 0.2) - : cs.primaryContainer, - borderRadius: BorderRadius.circular(8), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - child: Icon( - Symbols.file_download, - color: isMe ? Colors.white : cs.primary, - size: 22, + const SizedBox(height: 2), + Text( + sizeStr, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - name, - style: TextStyle( - color: textColor, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - 'Скачать • $sizeStr', - style: TextStyle(color: subtitleColor, fontSize: 12), - ), - ], - ), - ), - ], + ], + ), ), - ), - _buildMeta(ctx), - ], + const SizedBox(width: 12), + GestureDetector( + onTap: () {}, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: isMe + ? Colors.white.withValues(alpha: 0.15) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE5E5EA)), + shape: BoxShape.circle, + ), + child: Icon( + Symbols.download, + color: isMe ? Colors.white : cs.primary, + size: 18, + ), + ), + ), + ], + ), ); } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 2270210..dbaf79e 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -41,6 +41,10 @@ abstract class MessageAttachment { return LocationAttachment.fromMap(map); case 'CONTROL': return ControlAttachment.fromMap(map); + case 'SHARE': + return FileAttachment.fromMap(map); + case 'INLINE_KEYBOARD': + return UnknownAttachment(map); default: return UnknownAttachment(map); } @@ -125,8 +129,19 @@ class VideoAttachment extends MessageAttachment { }) : super(type: AttachmentType.video); factory VideoAttachment.fromMap(Map map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.from(previewRaw); + previewStr = String.fromCharCodes(bytes); + } catch (_) {} + } + return VideoAttachment( - previewData: map['previewData'] as String?, + previewData: previewStr, baseUrl: map['baseUrl'] as String?, videoId: map['videoId'] as int?, videoToken: map['videoToken'] as String?, @@ -170,8 +185,19 @@ class AudioAttachment extends MessageAttachment { }) : super(type: AttachmentType.audio); factory AudioAttachment.fromMap(Map map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.from(previewRaw); + previewStr = String.fromCharCodes(bytes); + } catch (_) {} + } + return AudioAttachment( - previewData: map['previewData'] as String?, + previewData: previewStr, baseUrl: map['baseUrl'] as String?, audioId: map['audioId'] as int?, audioToken: map['audioToken'] as String?, @@ -211,8 +237,19 @@ class FileAttachment extends MessageAttachment { }) : super(type: AttachmentType.file); factory FileAttachment.fromMap(Map map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.from(previewRaw); + previewStr = String.fromCharCodes(bytes); + } catch (_) {} + } + return FileAttachment( - previewData: map['previewData'] as String?, + previewData: previewStr, baseUrl: map['baseUrl'] as String?, fileId: map['fileId'] as int?, fileToken: map['fileToken'] as String?, @@ -250,8 +287,19 @@ class StickerAttachment extends MessageAttachment { }) : super(type: AttachmentType.sticker); factory StickerAttachment.fromMap(Map map) { + String? previewStr; + final previewRaw = map['previewData']; + if (previewRaw is String) { + previewStr = previewRaw; + } else if (previewRaw is List) { + try { + final bytes = List.from(previewRaw); + previewStr = String.fromCharCodes(bytes); + } catch (_) {} + } + return StickerAttachment( - previewData: map['previewData'] as String?, + previewData: previewStr, baseUrl: map['baseUrl'] as String?, stickerId: map['stickerId'] as String?, stickerPackId: map['stickerPackId'] as String?, From 99f5e587df41be9feb0b9fd0e78673ac03b3984e Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Mon, 6 Apr 2026 00:12:42 +0700 Subject: [PATCH 29/59] =?UTF-8?q?=D0=BD=D0=B5=D0=BA=D0=B0=D1=8F=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=BA=D0=BE=D1=81=D0=B0=D1=8F=20=D0=BA=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D1=8F=20=D0=BD=D0=BE=20=D0=BF=D0=BE=D1=85?= =?UTF-8?q?=D1=83=D0=B9=20=D1=8F=20=D1=81=D0=BF=D0=B0=D1=82=D1=8C=20=D1=85?= =?UTF-8?q?=D0=BE=D1=87=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 483cd94..61a2dbb 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -180,8 +180,22 @@ class MessagesModule { ); } - Future sendMessage(int accountId, int chatId, String text) async { - final payload = {'chatId': chatId, 'text': text}; + Future sendMessage( + int accountId, + int chatId, + String text, { + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'text': text, + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'elements': [], + 'attaches': [], + }, + 'notify': notify, + }; await _api.sendRequest(Opcode.msgSend, payload); } From 882bc8c62eac9f8aa0c8cb72b4e2e7c080c658d3 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 5 Apr 2026 20:59:30 +0300 Subject: [PATCH 30/59] feat(settings): add app version display in settings tab and update dependencies --- .../screens/profile/settings_tab.dart | 29 ++++++++++++++++- pubspec.lock | 32 ++++++++++++++----- pubspec.yaml | 1 + 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 3dc116c..4892345 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,6 +1,6 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; import 'devices_screen.dart'; import 'security_screen.dart'; @@ -16,11 +16,13 @@ class SettingsTab extends StatefulWidget { class _SettingsTabState extends State { ProfileData? _profile; bool _isPhoneVisible = false; + String? _appVersionLabel; @override void initState() { super.initState(); _loadProfile(); + _loadAppVersion(); } Future _loadProfile() async { @@ -28,6 +30,14 @@ class _SettingsTabState extends State { if (mounted) setState(() => _profile = p); } + Future _loadAppVersion() async { + final info = await PackageInfo.fromPlatform(); + if (!mounted) return; + setState(() { + _appVersionLabel = 'Версия ${info.version} (${info.buildNumber})'; + }); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -117,6 +127,23 @@ class _SettingsTabState extends State { ), ), ), + if (_appVersionLabel != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 28, 16, 12), + child: Center( + child: Text( + _appVersionLabel!, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.75), + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 120)), ], ), diff --git a/pubspec.lock b/pubspec.lock index ca2281c..a6437bc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: @@ -365,6 +365,22 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -614,10 +630,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" timezone: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2dd41b7..d849066 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: material_symbols_icons: ^4.2906.0 dynamic_color: ^1.8.1 shared_preferences: ^2.5.4 + package_info_plus: ^9.0.1 dev_dependencies: flutter_test: From 279bb87e6a1613559b253d7ae47b0e216737585f Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 5 Apr 2026 21:14:05 +0300 Subject: [PATCH 31/59] feat(settings): implement debug menu toggle in settings tab with version label tap detection --- .../screens/profile/debug_menu_screen.dart | 56 +++++++++ .../screens/profile/settings_tab.dart | 113 ++++++++++++++++-- 2 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 lib/frontend/screens/profile/debug_menu_screen.dart diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart new file mode 100644 index 0000000..dc83d3f --- /dev/null +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class DebugMenuScreen extends StatelessWidget { + const DebugMenuScreen({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Для разработчиков', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + ], + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 120)), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 4892345..4a37280 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'security_screen.dart'; import 'spoof_screen.dart'; @@ -17,6 +20,9 @@ class _SettingsTabState extends State { ProfileData? _profile; bool _isPhoneVisible = false; String? _appVersionLabel; + bool _debugMenuVisible = false; + int _versionSecretTapCount = 0; + Timer? _versionSecretTapResetTimer; @override void initState() { @@ -25,6 +31,31 @@ class _SettingsTabState extends State { _loadAppVersion(); } + @override + void dispose() { + _versionSecretTapResetTimer?.cancel(); + super.dispose(); + } + + void _scheduleVersionSecretTapReset() { + _versionSecretTapResetTimer?.cancel(); + _versionSecretTapResetTimer = Timer(const Duration(seconds: 2), () { + if (mounted) setState(() => _versionSecretTapCount = 0); + }); + } + + void _onVersionLabelTap() { + _scheduleVersionSecretTapReset(); + setState(() { + _versionSecretTapCount++; + if (_versionSecretTapCount >= 7) { + _versionSecretTapCount = 0; + _versionSecretTapResetTimer?.cancel(); + _debugMenuVisible = !_debugMenuVisible; + } + }); + } + Future _loadProfile() async { final p = await AppDatabase.loadActiveProfile(); if (mounted) setState(() => _profile = p); @@ -127,18 +158,86 @@ class _SettingsTabState extends State { ), ), ), + SliverToBoxAdapter( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 340), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) { + return ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: animation.value.clamp(0.0, 1.0), + child: FadeTransition( + opacity: animation, + child: child, + ), + ), + ); + }, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.topCenter, + clipBehavior: Clip.none, + children: [ + ...previousChildren, + ?currentChild, + ], + ); + }, + child: _debugMenuVisible + ? KeyedSubtree( + key: const ValueKey('developers_settings_row'), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildSection( + context, + cs, + items: [ + _SettingsItem( + icon: Symbols.construction, + label: 'Для разработчиков', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const DebugMenuScreen(), + ), + ); + }, + ), + ], + ), + ), + ) + : const SizedBox.shrink( + key: ValueKey('developers_settings_hidden'), + ), + ), + ), if (_appVersionLabel != null) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 28, 16, 12), child: Center( - child: Text( - _appVersionLabel!, - textAlign: TextAlign.center, - style: TextStyle( - color: cs.onSurfaceVariant.withValues(alpha: 0.75), - fontSize: 13, - fontWeight: FontWeight.w400, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onVersionLabelTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + child: Text( + _appVersionLabel!, + textAlign: TextAlign.center, + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.75), + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), ), ), ), From cd5fdcf2c52dbb580a1f1b222f9cdcfee3c4c749 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 5 Apr 2026 21:36:21 +0300 Subject: [PATCH 32/59] =?UTF-8?q?feat(settings):=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F,=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D1=82=D0=B0=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D0=BC=D1=8B=D0=B9=20FPS-=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/debug/fps_overlay_layer.dart | 145 ++++++++++++++++++ .../screens/profile/debug_menu_screen.dart | 68 ++++++++ lib/main.dart | 47 +++++- 3 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 lib/frontend/debug/fps_overlay_layer.dart diff --git a/lib/frontend/debug/fps_overlay_layer.dart b/lib/frontend/debug/fps_overlay_layer.dart new file mode 100644 index 0000000..c9af7b0 --- /dev/null +++ b/lib/frontend/debug/fps_overlay_layer.dart @@ -0,0 +1,145 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +class FpsOverlayLayer extends StatefulWidget { + const FpsOverlayLayer({super.key}); + + @override + State createState() => _FpsOverlayLayerState(); +} + +class _FpsOverlayLayerState extends State { + static const int _maxSamples = 90; + static const int _minUiRefreshMs = 160; + static const double _initialWidthGuess = 96; + static const double _initialHeightGuess = 36; + + final List _frameMicros = []; + final GlobalKey _badgeKey = GlobalKey(); + double _fps = 0; + DateTime _lastUiUpdate = DateTime.fromMillisecondsSinceEpoch(0); + double? _left; + double? _top; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addTimingsCallback(_onTimings); + } + + @override + void dispose() { + WidgetsBinding.instance.removeTimingsCallback(_onTimings); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_left != null && _top != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(_clampPositionToScreen); + } + }); + } + } + + void _ensureInitialPosition() { + if (_left != null) return; + final mq = MediaQuery.of(context); + final w = mq.size.width; + _left = w - _initialWidthGuess - 8; + _top = mq.padding.top + 8; + } + + void _clampPositionToScreen() { + if (_left == null || _top == null) return; + final mq = MediaQuery.of(context); + final screen = mq.size; + final topMin = mq.padding.top; + final bottomMax = screen.height - mq.padding.bottom; + + final box = _badgeKey.currentContext?.findRenderObject() as RenderBox?; + final bw = box?.hasSize == true + ? box!.size.width + : _initialWidthGuess; + final bh = box?.hasSize == true + ? box!.size.height + : _initialHeightGuess; + + _left = _left!.clamp(0.0, math.max(0.0, screen.width - bw)); + _top = _top!.clamp(topMin, math.max(topMin, bottomMax - bh)); + } + + void _onTimings(List timings) { + for (final t in timings) { + final us = t.totalSpan.inMicroseconds; + if (us <= 0) continue; + _frameMicros.add(us); + while (_frameMicros.length > _maxSamples) { + _frameMicros.removeAt(0); + } + } + final now = DateTime.now(); + if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) { + return; + } + _lastUiUpdate = now; + if (!mounted || _frameMicros.isEmpty) return; + final sum = _frameMicros.fold(0, (a, b) => a + b); + final avg = sum / _frameMicros.length; + final fps = avg > 0 ? (1000000.0 / avg).clamp(0.0, 999.0) : 0.0; + setState(() => _fps = fps); + } + + @override + Widget build(BuildContext context) { + _ensureInitialPosition(); + _clampPositionToScreen(); + + return Positioned( + left: _left, + top: _top, + child: MouseRegion( + cursor: SystemMouseCursors.move, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (details) { + setState(() { + _left = _left! + details.delta.dx; + _top = _top! + details.delta.dy; + _clampPositionToScreen(); + }); + }, + child: Material( + key: _badgeKey, + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xCC000000), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${_fps.round()} FPS', + style: TextStyle( + color: _fps >= 55 + ? const Color(0xFFB8F5C6) + : _fps >= 30 + ? const Color(0xFFFFE082) + : const Color(0xFFFFAB91), + fontSize: 13, + fontWeight: FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index dc83d3f..5f7de26 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart'; + class DebugMenuScreen extends StatelessWidget { const DebugMenuScreen({super.key}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final appState = KometApp.stateOf(context); return Scaffold( backgroundColor: cs.surface, @@ -47,6 +50,71 @@ class DebugMenuScreen extends StatelessWidget { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: appState == null + ? const SizedBox.shrink() + : ValueListenableBuilder( + valueListenable: appState.fpsOverlayEnabled, + builder: (context, fpsOn, _) { + 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.speed, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Оверлей FPS', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Показ текущего фреймрейта поверх интерфейса', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: fpsOn, + onChanged: (v) { + appState.setFpsOverlayEnabled(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 120)), ], ), diff --git a/lib/main.dart b/lib/main.dart index 691248e..82ae9b6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'backend/modules/messages.dart'; import 'core/storage/app_database.dart'; import 'core/storage/token_storage.dart'; import 'core/protocol/packet.dart'; +import 'frontend/debug/fps_overlay_layer.dart'; import 'frontend/screens/auth/login_screen.dart'; import 'frontend/screens/chats/chat_list_screen.dart'; import 'frontend/widgets/custom_notification.dart'; @@ -35,13 +36,25 @@ void main() async { await AppDatabase.init(); await api.connect(); final initialLocale = await _loadInitialLocale(); - runApp(KometApp(initialLocale: initialLocale)); + final prefs = await SharedPreferences.getInstance(); + final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; + runApp( + KometApp( + initialLocale: initialLocale, + initialFpsOverlay: initialFpsOverlay, + ), + ); } class KometApp extends StatefulWidget { - const KometApp({super.key, required this.initialLocale}); + const KometApp({ + super.key, + required this.initialLocale, + this.initialFpsOverlay = false, + }); final Locale initialLocale; + final bool initialFpsOverlay; static final navigatorKey = GlobalKey(); static KometAppState? stateOf(BuildContext context) { @@ -57,6 +70,8 @@ class KometAppState extends State { late Locale _locale; bool _isLoggingOut = false; + late final ValueNotifier fpsOverlayEnabled = + ValueNotifier(widget.initialFpsOverlay); @override void initState() { @@ -95,6 +110,19 @@ class KometAppState extends State { }); } + @override + void dispose() { + fpsOverlayEnabled.dispose(); + super.dispose(); + } + + Future setFpsOverlayEnabled(bool value) async { + if (fpsOverlayEnabled.value == value) return; + fpsOverlayEnabled.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('dev_fps_overlay', value); + } + Future applyLocale(Locale locale) async { if (!AppLocalizations.supportedLocales.any( (l) => l.languageCode == locale.languageCode, @@ -184,6 +212,21 @@ class KometAppState extends State { ), ), navigatorKey: KometApp.navigatorKey, + builder: (context, child) { + return ValueListenableBuilder( + valueListenable: fpsOverlayEnabled, + builder: (context, fpsOn, _) { + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + child ?? const SizedBox.shrink(), + if (fpsOn) const FpsOverlayLayer(), + ], + ); + }, + ); + }, home: const _StartupScreen(), ); }, From a4719c91b80d8f178bfd87d8d7f455b459941435 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Mon, 6 Apr 2026 18:24:45 +0700 Subject: [PATCH 33/59] olo --- lib/frontend/screens/chats/chat_screen.dart | 2 +- pubspec.lock | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 3a611ab..2944231 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -168,7 +168,7 @@ class _ChatScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( - _scrollController.position.maxScrollExtent, + _scrollController.position.minScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); diff --git a/pubspec.lock b/pubspec.lock index a6437bc..5a852a3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -630,10 +630,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: From 55313abb12e5be25ef96630814f2060726ba885a Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 6 Apr 2026 15:06:12 +0300 Subject: [PATCH 34/59] feat(devices): QR approve login for web and desktop MAX --- android/app/src/main/AndroidManifest.xml | 1 + android/gradle.properties | 1 + ios/Runner/Info.plist | 2 + lib/backend/modules/account.dart | 16 ++ .../screens/profile/devices_screen.dart | 203 ++++++++++++++++++ .../screens/profile/web_qr_scan_screen.dart | 117 ++++++++++ pubspec.lock | 8 + pubspec.yaml | 1 + 8 files changed, 349 insertions(+) create mode 100644 lib/frontend/screens/profile/web_qr_scan_screen.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a503dd6..b2c538d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + UIApplicationSupportsIndirectInputEvents + NSCameraUsageDescription + Камера нужна для сканирования QR-кода входа в веб-версию и приложение MAX на компьютере. diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 67aeb2d..bb4f03d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -288,6 +288,22 @@ class AccountModule { _checkPacketError(packet, 'terminateOtherSessions'); } + Future authorizeWebQrLogin(String qrLink) async { + _ensureOnline(); + final link = qrLink.trim(); + if (link.isEmpty) { + throw ArgumentError('Пустая ссылка из QR'); + } + + await _api.sendRequest(Opcode.ping, {'interactive': true}); + await _api.sendRequest(Opcode.sessionsInfo, {}); + await Future.delayed(const Duration(milliseconds: 300)); + final packet = await _api.sendRequest(Opcode.authQrApprove, { + 'qrLink': link, + }); + _checkPacketError(packet, 'authorizeWebQrLogin'); + } + Future switchAccount(int accountId) async { final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 40ab60d..8030921 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -1,12 +1,15 @@ import 'dart:io'; import 'dart:convert'; import 'dart:math'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show SessionInfo; import '../../widgets/custom_notification.dart'; +import 'web_qr_scan_screen.dart'; class DevicesScreen extends StatefulWidget { const DevicesScreen({super.key}); @@ -57,6 +60,188 @@ class _DevicesScreenState extends State } } + Future _showPasteQrDialog() async { + final tec = TextEditingController(); + try { + return await showDialog( + context: context, + builder: (dialogContext) { + final cs = Theme.of(dialogContext).colorScheme; + return AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + title: Text( + 'Ссылка из QR', + style: GoogleFonts.outfit( + fontWeight: FontWeight.w600, + fontSize: 18, + color: cs.onSurface, + ), + ), + content: TextField( + controller: tec, + decoration: const InputDecoration( + hintText: 'Вставьте содержимое QR-кода', + ), + autofocus: true, + maxLines: 4, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text( + 'Отмена', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ), + FilledButton( + onPressed: () { + final v = tec.text.trim(); + Navigator.pop(dialogContext, v.isEmpty ? null : v); + }, + child: const Text('Подтвердить'), + ), + ], + ); + }, + ); + } finally { + tec.dispose(); + } + } + + Future _confirmQrWebLoginSheet() async { + final agreed = await showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) { + final cs = Theme.of(sheetContext).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + Text( + 'Вход по QR', + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + 'Вы точно хотите войти в аккаунт через веб или приложение MAX на компьютере?', + style: TextStyle( + fontSize: 15, + height: 1.35, + color: cs.onSurfaceVariant, + ), + ), + const SizedBox(height: 28), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => + Navigator.of(sheetContext).pop(false), + child: Text( + 'Отмена', + style: TextStyle(color: cs.onSurface), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: () => + Navigator.of(sheetContext).pop(true), + child: const Text('Войти'), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + return agreed ?? false; + } + + Future _startWebQrAuth() async { + final canScan = !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); + + final String? qr; + if (canScan) { + qr = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const WebQrScanScreen()), + ); + } else { + qr = await _showPasteQrDialog(); + } + + if (!mounted) return; + if (qr == null || qr.trim().isEmpty) return; + + final confirmed = await _confirmQrWebLoginSheet(); + if (!mounted) return; + if (!confirmed) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + final cs = Theme.of(ctx).colorScheme; + return PopScope( + canPop: false, + child: Center( + child: Card( + color: cs.surfaceContainerHigh, + child: const Padding( + padding: EdgeInsets.all(28), + child: CircularProgressIndicator(), + ), + ), + ), + ); + }, + ); + + try { + await accountModule.authorizeWebQrLogin(qr.trim()); + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + showCustomNotification(context, 'Вход по QR подтверждён'); + _loadSessions(); + } + } catch (e) { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + showCustomNotification(context, 'Ошибка: $e'); + } + } + } + Future _terminateOthers() async { try { await accountModule.terminateOtherSessions(); @@ -229,6 +414,24 @@ class _DevicesScreenState extends State height: 1.3, ), ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _startWebQrAuth, + icon: const Icon(Symbols.qr_code_scanner, size: 22), + label: Text( + 'Сканировать QR', + style: GoogleFonts.outfit( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + ), + ), ], ), ), diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart new file mode 100644 index 0000000..c512811 --- /dev/null +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class WebQrScanScreen extends StatefulWidget { + const WebQrScanScreen({super.key}); + + @override + State createState() => _WebQrScanScreenState(); +} + +class _WebQrScanScreenState extends State { + final MobileScannerController _controller = MobileScannerController(); + bool _handled = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_handled) return; + final barcodes = capture.barcodes; + if (barcodes.isEmpty) return; + final raw = barcodes.first.rawValue; + if (raw == null || raw.isEmpty) return; + _handled = true; + _controller.stop(); + if (mounted) Navigator.of(context).pop(raw); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Symbols.chevron_left, size: 28), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'QR для веба и ПК', + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + centerTitle: true, + actions: [ + IconButton( + icon: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, _) { + final on = state.torchState == TorchState.on; + return Icon( + on ? Symbols.flash_on : Symbols.flash_off, + color: Colors.white, + ); + }, + ), + onPressed: () => _controller.toggleTorch(), + ), + ], + ), + body: Stack( + fit: StackFit.expand, + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + errorBuilder: (context, error) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + error.errorDetails?.message ?? 'Камера недоступна', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70, fontSize: 15), + ), + ), + ); + }, + ), + Positioned( + left: 0, + right: 0, + bottom: 48, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + 'Наведите камеру на QR-код на экране компьютера', + textAlign: TextAlign.center, + style: GoogleFonts.outfit( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Colors.white, + shadows: const [ + Shadow( + blurRadius: 8, + color: Colors.black54, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5a852a3..62a257f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -341,6 +341,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce + url: "https://pub.dev" + source: hosted + version: "7.2.0" msgpack_dart: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d849066..0341c9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: dynamic_color: ^1.8.1 shared_preferences: ^2.5.4 package_info_plus: ^9.0.1 + mobile_scanner: ^7.2.0 dev_dependencies: flutter_test: From 57fbaece036335b73ec8b54e0b593f14ad4eb67b Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 6 Apr 2026 15:43:33 +0300 Subject: [PATCH 35/59] feat(qr): rounded finder overlay, dim mask, green lock, settle delay before pop --- .../screens/profile/web_qr_scan_screen.dart | 436 ++++++++++++++++-- pubspec.lock | 16 +- 2 files changed, 404 insertions(+), 48 deletions(-) diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart index c512811..a859b5a 100644 --- a/lib/frontend/screens/profile/web_qr_scan_screen.dart +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -1,4 +1,8 @@ +import 'dart:async'; +import 'dart:math' as math; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; @@ -12,23 +16,42 @@ class WebQrScanScreen extends StatefulWidget { class _WebQrScanScreenState extends State { final MobileScannerController _controller = MobileScannerController(); + static const _settleAfterDetect = Duration(milliseconds: 720); + bool _handled = false; + String? _armedRaw; + Timer? _completeTimer; @override void dispose() { + _completeTimer?.cancel(); _controller.dispose(); super.dispose(); } + void _completeScan() { + if (!mounted || _handled) return; + final value = _armedRaw; + if (value == null || value.isEmpty) return; + _handled = true; + _completeTimer?.cancel(); + _completeTimer = null; + unawaited(_controller.stop()); + if (mounted) Navigator.of(context).pop(value); + } + void _onDetect(BarcodeCapture capture) { if (_handled) return; final barcodes = capture.barcodes; if (barcodes.isEmpty) return; final raw = barcodes.first.rawValue; if (raw == null || raw.isEmpty) return; - _handled = true; - _controller.stop(); - if (mounted) Navigator.of(context).pop(raw); + + if (_armedRaw != raw) { + _armedRaw = raw; + _completeTimer?.cancel(); + _completeTimer = Timer(_settleAfterDetect, _completeScan); + } } @override @@ -68,50 +91,383 @@ class _WebQrScanScreenState extends State { ), ], ), - body: Stack( - fit: StackFit.expand, - children: [ - MobileScanner( - controller: _controller, - onDetect: _onDetect, - errorBuilder: (context, error) { - return Center( + body: LayoutBuilder( + builder: (context, constraints) { + final layoutSize = Size(constraints.maxWidth, constraints.maxHeight); + return Stack( + fit: StackFit.expand, + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + fit: BoxFit.cover, + errorBuilder: (context, error) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + error.errorDetails?.message ?? 'Камера недоступна', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontSize: 15, + ), + ), + ), + ); + }, + ), + _TelegramStyleFinderOverlay( + layoutSize: layoutSize, + controller: _controller, + ), + Positioned( + left: 0, + right: 0, + bottom: 48, child: Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( - error.errorDetails?.message ?? 'Камера недоступна', + 'Наведите камеру на QR-код на экране компьютера', textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70, fontSize: 15), + style: GoogleFonts.outfit( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Colors.white, + shadows: const [ + Shadow( + blurRadius: 8, + color: Colors.black54, + ), + ], + ), ), ), - ); - }, - ), - Positioned( - left: 0, - right: 0, - bottom: 48, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text( - 'Наведите камеру на QR-код на экране компьютера', - textAlign: TextAlign.center, - style: GoogleFonts.outfit( - fontSize: 15, - fontWeight: FontWeight.w500, - color: Colors.white, - shadows: const [ - Shadow( - blurRadius: 8, - color: Colors.black54, - ), - ], - ), ), - ), - ), - ], + ], + ); + }, ), ); } } + +class _TelegramStyleFinderOverlay extends StatefulWidget { + const _TelegramStyleFinderOverlay({ + required this.layoutSize, + required this.controller, + }); + + final Size layoutSize; + final MobileScannerController controller; + + @override + State<_TelegramStyleFinderOverlay> createState() => + _TelegramStyleFinderOverlayState(); +} + +class _TelegramStyleFinderOverlayState extends State<_TelegramStyleFinderOverlay> + with SingleTickerProviderStateMixin { + static const _animDuration = Duration(milliseconds: 320); + static const _snapPx = 14.0; + static const _frameCornerRadius = 16.0; + static const _qrBoundsPadding = 14.0; + + late AnimationController _ac; + late CurvedAnimation _curved; + Rect _fromR = Rect.zero; + Rect _toR = Rect.zero; + StreamSubscription? _barcodeSub; + bool _qrInView = false; + + @override + void initState() { + super.initState(); + final d = _defaultFinderRect(widget.layoutSize); + _fromR = d; + _toR = d; + _ac = AnimationController(vsync: this, duration: _animDuration); + _curved = CurvedAnimation(parent: _ac, curve: Curves.easeOutCubic); + _ac.addListener(() => setState(() {})); + _barcodeSub = widget.controller.barcodes.listen(_onBarcodeFrame); + widget.controller.addListener(_onControllerUpdate); + } + + @override + void didUpdateWidget(covariant _TelegramStyleFinderOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.layoutSize != widget.layoutSize) { + final d = _defaultFinderRect(widget.layoutSize); + _fromR = d; + _toR = d; + _ac.value = 1.0; + } + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerUpdate); + unawaited(_barcodeSub?.cancel()); + _curved.dispose(); + _ac.dispose(); + super.dispose(); + } + + void _onControllerUpdate() { + final v = widget.controller.value; + if (!v.isInitialized || !v.isRunning || v.error != null) { + if (mounted) setState(() {}); + } + } + + void _onBarcodeFrame(BarcodeCapture cap) { + if (!mounted) return; + final v = widget.controller.value; + if (!v.isInitialized || !v.isRunning || v.error != null) return; + + final defaultRect = _defaultFinderRect(widget.layoutSize); + final mapped = _targetFinderRectFromCapture(cap, v); + final inView = mapped != null; + if (inView != _qrInView) { + setState(() => _qrInView = inView); + } + final next = mapped ?? defaultRect; + if (_rectClose(_toR, next) && _ac.isCompleted) { + return; + } + _animateTo(next); + } + + Rect _defaultFinderRect(Size s) { + final side = math.min(s.width, s.height) * 0.68; + final left = (s.width - side) / 2; + final top = (s.height - side) / 2 - s.height * 0.06; + return Rect.fromLTWH(left, top, side, side); + } + + bool _rectClose(Rect a, Rect b) { + return (a.left - b.left).abs() < _snapPx && + (a.top - b.top).abs() < _snapPx && + (a.width - b.width).abs() < _snapPx && + (a.height - b.height).abs() < _snapPx; + } + + Rect _interpolatedFinderRect() { + if (_fromR.isEmpty || _toR.isEmpty) { + return _defaultFinderRect(widget.layoutSize); + } + return Rect.lerp(_fromR, _toR, _curved.value)!; + } + + void _animateTo(Rect target) { + if (_rectClose(_toR, target) && _ac.isCompleted) { + return; + } + double t; + if (_ac.status == AnimationStatus.completed) { + t = 1.0; + } else if (_ac.status == AnimationStatus.dismissed) { + t = 0.0; + } else { + t = _curved.value; + } + _fromR = Rect.lerp(_fromR, _toR, t)!; + _toR = target; + _ac.forward(from: 0); + } + + Rect? _targetFinderRectFromCapture( + BarcodeCapture? cap, + MobileScannerState scannerState, + ) { + if (cap == null || cap.barcodes.isEmpty || widget.layoutSize.isEmpty) { + return null; + } + final b = cap.barcodes.first; + if (b.corners.length < 4) return null; + + final refSize = !cap.size.isEmpty + ? cap.size + : (!scannerState.size.isEmpty ? scannerState.size : Size.zero); + if (refSize.isEmpty) return null; + + final mapped = _mapBarcodeCornersToLayout( + b.corners.take(4).toList(), + refSize, + widget.layoutSize, + scannerState.deviceOrientation, + ); + if (mapped.length < 4) return null; + return _axisSquareAroundCorners( + mapped, + widget.layoutSize, + padding: _qrBoundsPadding, + ); + } + + static Rect _axisSquareAroundCorners( + List corners, + Size layout, { + required double padding, + }) { + var minX = double.infinity; + var maxX = double.negativeInfinity; + var minY = double.infinity; + var maxY = double.negativeInfinity; + for (final o in corners) { + minX = math.min(minX, o.dx); + maxX = math.max(maxX, o.dx); + minY = math.min(minY, o.dy); + maxY = math.max(maxY, o.dy); + } + final bw = maxX - minX + 2 * padding; + final bh = maxY - minY + 2 * padding; + final side = math.max(bw, bh); + final cx = (minX + maxX) / 2; + final cy = (minY + maxY) / 2; + var r = Rect.fromCenter(center: Offset(cx, cy), width: side, height: side); + r = r.intersect(Rect.fromLTWH(0, 0, layout.width, layout.height)); + if (r.isEmpty) { + return Rect.fromLTWH(0, 0, layout.shortestSide * 0.5, layout.shortestSide * 0.5); + } + return r; + } + + static RRect _finderRRect(Rect rect, double maxRadius) { + final r = math.min( + maxRadius, + math.min(rect.width, rect.height) * 0.14, + ); + return RRect.fromRectAndRadius(rect, Radius.circular(r)); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.controller, + builder: (context, state, _) { + if (!state.isInitialized || !state.isRunning || state.error != null) { + return const SizedBox.expand(); + } + + final finderRect = _interpolatedFinderRect(); + final rrect = _finderRRect(finderRect, _frameCornerRadius); + final frameColor = + _qrInView ? const Color(0xFF4ADE80) : Colors.white; + return IgnorePointer( + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + size: widget.layoutSize, + painter: _ScannerDimOutsideRRectPainter( + hole: rrect, + dimColor: Colors.black.withValues(alpha: 0.58), + ), + ), + CustomPaint( + size: widget.layoutSize, + painter: _FinderRoundedSquareStrokePainter( + rrect: rrect, + color: frameColor, + strokeWidth: 3.2, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _ScannerDimOutsideRRectPainter extends CustomPainter { + _ScannerDimOutsideRRectPainter({ + required this.hole, + required this.dimColor, + }); + + final RRect hole; + final Color dimColor; + + @override + void paint(Canvas canvas, Size size) { + final outer = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + final inner = Path()..addRRect(hole); + final mask = Path.combine(PathOperation.difference, outer, inner); + canvas.drawPath(mask, Paint()..color = dimColor); + } + + @override + bool shouldRepaint(covariant _ScannerDimOutsideRRectPainter oldDelegate) { + if (oldDelegate.dimColor != dimColor) return true; + return oldDelegate.hole != hole; + } +} + +class _FinderRoundedSquareStrokePainter extends CustomPainter { + _FinderRoundedSquareStrokePainter({ + required this.rrect, + required this.color, + required this.strokeWidth, + }); + + final RRect rrect; + final Color color; + final double strokeWidth; + + @override + void paint(Canvas canvas, Size size) { + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.45) + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + 2.5 + ..strokeCap = StrokeCap.round + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3); + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + canvas.drawRRect(rrect, shadowPaint); + canvas.drawRRect(rrect, paint); + } + + @override + bool shouldRepaint(covariant _FinderRoundedSquareStrokePainter oldDelegate) { + return oldDelegate.rrect != rrect || + oldDelegate.color != color || + oldDelegate.strokeWidth != strokeWidth; + } +} + +List _mapBarcodeCornersToLayout( + List barcodeCorners, + Size cameraPreviewSize, + Size layoutSize, + DeviceOrientation deviceOrientation, +) { + if (barcodeCorners.length < 4 || cameraPreviewSize.isEmpty) { + return []; + } + + final isLandscape = deviceOrientation == DeviceOrientation.landscapeLeft || + deviceOrientation == DeviceOrientation.landscapeRight; + final cam = isLandscape ? cameraPreviewSize.flipped : cameraPreviewSize; + + final wr = layoutSize.width / cam.width; + final hr = layoutSize.height / cam.height; + final ratio = math.max(wr, hr); + final hPad = (cam.width * ratio - layoutSize.width) / 2; + final vPad = (cam.height * ratio - layoutSize.height) / 2; + + return [ + for (final o in barcodeCorners) + Offset( + o.dx * ratio - hPad, + o.dy * ratio - vPad, + ), + ]; +} + diff --git a/pubspec.lock b/pubspec.lock index 62a257f..66aac21 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" timezone: dependency: "direct main" description: From f9be6abd4a4777e1924b781699e6c483dcd56aae Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Mon, 6 Apr 2026 22:04:22 +0700 Subject: [PATCH 36/59] =?UTF-8?q?=D0=B1=D0=BB=D1=8F=20=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=20=D0=BC=D0=BD=D0=B5=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=83=20=D0=BC=D0=B5=D0=BD=D1=8F=20=D1=82?= =?UTF-8?q?=D0=B5=D0=BC=D0=BF=D0=B1=D0=BB=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/account.dart | 476 ++++++ lib/core/storage/app_database.dart | 63 +- lib/core/transport/sender.dart | 2 - .../profile/password_entry_screen.dart | 1488 +++++++++++++++++ .../screens/profile/security_screen.dart | 898 +++++++++- pubspec.lock | 16 +- 6 files changed, 2843 insertions(+), 100 deletions(-) create mode 100644 lib/frontend/screens/profile/password_entry_screen.dart diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index bb4f03d..52fe39d 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; @@ -9,6 +10,184 @@ import 'chats.dart'; import 'contacts.dart'; import 'folders.dart'; +class PrivacyConfig { + final String searchByPhone; + final String incomingCall; + final bool doubleTapReactionDisabled; + final bool safeModeNoPin; + final String? doubleTapReactionValue; + final String familyProtection; + final bool pushDetails; + final bool hidden; + final String chatsInvite; + final bool pushNewContacts; + final bool unsafeFiles; + final String inactiveTtl; + final bool showReadMark; + final bool altKeyboard; + final bool contentLevelAccess; + final String stickersSuggest; + final bool safeMode; + final bool audioTranscriptionEnabled; + final String hash; + + const PrivacyConfig({ + required this.searchByPhone, + required this.incomingCall, + required this.doubleTapReactionDisabled, + required this.safeModeNoPin, + this.doubleTapReactionValue, + required this.familyProtection, + required this.pushDetails, + required this.hidden, + required this.chatsInvite, + required this.pushNewContacts, + required this.unsafeFiles, + required this.inactiveTtl, + required this.showReadMark, + required this.altKeyboard, + required this.contentLevelAccess, + required this.stickersSuggest, + required this.safeMode, + required this.audioTranscriptionEnabled, + required this.hash, + }); + + factory PrivacyConfig.fromMap(Map map) { + return PrivacyConfig( + searchByPhone: map['SEARCH_BY_PHONE']?.toString() ?? 'ALL', + incomingCall: map['INCOMING_CALL']?.toString() ?? 'CONTACTS', + doubleTapReactionDisabled: map['DOUBLE_TAP_REACTION_DISABLED'] ?? false, + safeModeNoPin: map['SAFE_MODE_NO_PIN'] ?? false, + doubleTapReactionValue: map['DOUBLE_TAP_REACTION_VALUE']?.toString(), + familyProtection: map['FAMILY_PROTECTION']?.toString() ?? 'OFF', + pushDetails: map['PUSH_DETAILS'] ?? false, + hidden: map['HIDDEN'] ?? true, + chatsInvite: map['CHATS_INVITE']?.toString() ?? 'CONTACTS', + pushNewContacts: map['PUSH_NEW_CONTACTS'] ?? false, + unsafeFiles: map['UNSAFE_FILES'] ?? true, + inactiveTtl: map['INACTIVE_TTL']?.toString() ?? '6M', + showReadMark: map['SHOW_READ_MARK'] ?? true, + altKeyboard: map['ALT_KEYBOARD'] ?? false, + contentLevelAccess: map['CONTENT_LEVEL_ACCESS'] ?? false, + stickersSuggest: map['STICKERS_SUGGEST']?.toString() ?? 'ON', + safeMode: map['SAFE_MODE'] ?? false, + audioTranscriptionEnabled: map['AUDIO_TRANSCRIPTION_ENABLED'] ?? true, + hash: map['hash']?.toString() ?? '', + ); + } + + String toJson() => jsonEncode({ + 'SEARCH_BY_PHONE': searchByPhone, + 'INCOMING_CALL': incomingCall, + 'DOUBLE_TAP_REACTION_DISABLED': doubleTapReactionDisabled, + 'SAFE_MODE_NO_PIN': safeModeNoPin, + 'DOUBLE_TAP_REACTION_VALUE': doubleTapReactionValue, + 'FAMILY_PROTECTION': familyProtection, + 'PUSH_DETAILS': pushDetails, + 'HIDDEN': hidden, + 'CHATS_INVITE': chatsInvite, + 'PUSH_NEW_CONTACTS': pushNewContacts, + 'UNSAFE_FILES': unsafeFiles, + 'INACTIVE_TTL': inactiveTtl, + 'SHOW_READ_MARK': showReadMark, + 'ALT_KEYBOARD': altKeyboard, + 'CONTENT_LEVEL_ACCESS': contentLevelAccess, + 'STICKERS_SUGGEST': stickersSuggest, + 'SAFE_MODE': safeMode, + 'AUDIO_TRANSCRIPTION_ENABLED': audioTranscriptionEnabled, + 'hash': hash, + }); + + factory PrivacyConfig.fromJson(String json) { + try { + final map = jsonDecode(json) as Map; + return PrivacyConfig.fromMap(map); + } catch (_) { + return PrivacyConfig.empty(); + } + } + + static PrivacyConfig empty() { + return const PrivacyConfig( + searchByPhone: 'ALL', + incomingCall: 'CONTACTS', + doubleTapReactionDisabled: false, + safeModeNoPin: false, + familyProtection: 'OFF', + pushDetails: false, + hidden: true, + chatsInvite: 'CONTACTS', + pushNewContacts: false, + unsafeFiles: true, + inactiveTtl: '6M', + showReadMark: true, + altKeyboard: false, + contentLevelAccess: false, + stickersSuggest: 'ON', + safeMode: false, + audioTranscriptionEnabled: true, + hash: '', + ); + } +} + +class BlockedContact { + final int id; + final String? firstName; + final String? lastName; + final String? baseUrl; + final int? photoId; + final String status; + final int registrationTime; + final int updateTime; + + const BlockedContact({ + required this.id, + this.firstName, + this.lastName, + this.baseUrl, + this.photoId, + required this.status, + required this.registrationTime, + required this.updateTime, + }); + + factory BlockedContact.fromMap(Map map) { + String? firstName; + String? lastName; + final names = map['names'] as List?; + if (names != null && names.isNotEmpty) { + for (final n in names) { + if (n is Map) { + firstName = n['firstName'] as String?; + lastName = n['lastName'] as String?; + if (n['type'] == 'ONEME') break; + } + } + } + + return BlockedContact( + id: map['id'] as int? ?? 0, + firstName: firstName, + lastName: lastName, + baseUrl: map['baseUrl'] as String?, + photoId: map['photoId'] as int?, + status: map['status']?.toString() ?? 'BLOCKED', + registrationTime: map['registrationTime'] as int? ?? 0, + updateTime: map['updateTime'] as int? ?? 0, + ); + } +} + +class TwoFactorDetails { + final bool enabled; + final String? email; + final String? hint; + + const TwoFactorDetails({required this.enabled, this.email, this.hint}); +} + enum AuthRequestType { startAuth('START_AUTH'), resend('RESEND'), @@ -182,6 +361,303 @@ class AccountModule { Stream get loginStatusStream => _loginStatusController.stream; + Future getPrivacyConfig() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + final saved = await AppDatabase.getPrivacyConfig(accountId); + if (saved != null) { + return PrivacyConfig.fromJson(saved); + } + } + return PrivacyConfig.empty(); + } + + Future> getBlockedContacts() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.contactList, { + 'status': 'BLOCKED', + 'count': 100, + 'from': 0, + }); + _checkPacketError(packet, 'getBlockedContacts'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'getBlockedContacts: неожиданный тип payload: ${data.runtimeType}', + ); + } + final contacts = data['contacts'] as List?; + if (contacts == null) return []; + return contacts + .whereType() + .map((c) => BlockedContact.fromMap(c.cast())) + .toList(); + } + + Future updatePrivacyConfig( + Map settings, + ) async { + _ensureOnline(); + final payload = { + 'settings': {'user': settings}, + }; + final packet = await _api.sendRequest(Opcode.config, payload); + _checkPacketError(packet, 'updatePrivacyConfig'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'updatePrivacyConfig: неожиданный тип payload: ${data.runtimeType}', + ); + } + final user = data['user']; + if (user is! Map) { + throw Exception('updatePrivacyConfig: отсутствует user в payload'); + } + final config = PrivacyConfig.fromMap(user.cast()); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await AppDatabase.savePrivacyConfig(accountId, config.toJson()); + } + return config; + } + + // 2FA Creation (when not set) + Future create2faTrack() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authCreateTrack, {'type': 0}); + _checkPacketError(packet, 'create2faTrack'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'create2faTrack: неожиданный тип payload: ${data.runtimeType}', + ); + } + final trackId = data['trackId'] as String?; + if (trackId == null) { + throw Exception('create2faTrack: отсутствует trackId'); + } + return trackId; + } + + Future set2faPassword(String trackId, String password) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authValidatePassword, { + 'trackId': trackId, + 'password': password, + }); + _checkPacketError(packet, 'set2faPassword'); + if (packet.payload != null && packet.payload is! Map) { + throw Exception('set2faPassword: неожиданный ответ'); + } + } + + Future set2faHint(String trackId, String hint) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authValidateHint, { + 'trackId': trackId, + 'hint': hint, + }); + _checkPacketError(packet, 'set2faHint'); + if (packet.payload != null && packet.payload is! Map) { + throw Exception('set2faHint: неожиданный ответ'); + } + } + + Future verify2faEmail(String trackId, String email) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authVerifyEmail, { + 'trackId': trackId, + 'email': email, + }); + _checkPacketError(packet, 'verify2faEmail'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'verify2faEmail: неожиданный тип payload: ${data.runtimeType}', + ); + } + final blockingDuration = data['blockingDuration'] as int? ?? 60; + return blockingDuration; + } + + Future verify2faCode(String trackId, String code) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authCheckEmail, { + 'trackId': trackId, + 'verifyCode': code, + }); + _checkPacketError(packet, 'verify2faCode'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'verify2faCode: неожиданный тип payload: ${data.runtimeType}', + ); + } + final email = data['email'] as String? ?? ''; + return email; + } + + Future confirm2fa({ + required String trackId, + required String password, + String? hint, + }) async { + _ensureOnline(); + final payload = { + 'expectedCapabilities': [0, 3, 4], + 'trackId': trackId, + 'password': password, + }; + if (hint != null) payload['hint'] = hint; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'confirm2fa'); + return _processProfileUpdate(packet); + } + + // 2FA Management (when already set) + Future enter2faPanel() async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authCreateTrack, {'type': 0}); + _checkPacketError(packet, 'enter2faPanel'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'enter2faPanel: неожиданный тип payload: ${data.runtimeType}', + ); + } + final trackId = data['trackId'] as String?; + if (trackId == null) { + throw Exception('enter2faPanel: отсутствует trackId'); + } + return trackId; + } + + Future get2faDetails(String trackId) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.auth2faDetails, { + 'trackId': trackId, + }); + _checkPacketError(packet, 'get2faDetails'); + final data = packet.payload; + if (data is! Map) { + throw Exception( + 'get2faDetails: неожиданный тип payload: ${data.runtimeType}', + ); + } + final password = data['password'] as Map?; + return TwoFactorDetails( + enabled: password?['enabled'] ?? false, + email: password?['email'] as String?, + hint: password?['hint'] as String?, + ); + } + + Future check2faPassword(String trackId, String password) async { + _ensureOnline(); + final packet = await _api.sendRequest(Opcode.authLoginCheckPassword, { + 'trackId': trackId, + 'password': password, + }); + _checkPacketError(packet, 'check2faPassword'); + final data = packet.payload; + if (data is Map && data['error'] != null) { + throw Exception('Неверный пароль'); + } + } + + Future update2faPassword({ + required String trackId, + required String newPassword, + String? hint, + }) async { + _ensureOnline(); + final validatePacket = await _api.sendRequest(Opcode.authValidatePassword, { + 'trackId': trackId, + 'password': newPassword, + }); + _checkPacketError(validatePacket, 'update2faPassword: validate'); + if (validatePacket.payload != null && validatePacket.payload is! Map) { + throw Exception('update2faPassword: неожиданный ответ при валидации'); + } + + if (hint != null) { + final hintPacket = await _api.sendRequest(Opcode.authValidateHint, { + 'trackId': trackId, + 'hint': hint, + }); + _checkPacketError(hintPacket, 'update2faPassword: hint'); + } + + final payload = { + 'expectedCapabilities': [1, 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); + } + + Future update2faEmail({ + required String trackId, + required String email, + required String code, + }) async { + _ensureOnline(); + final verifyPacket = await _api.sendRequest(Opcode.authVerifyEmail, { + 'trackId': trackId, + 'email': email, + }); + _checkPacketError(verifyPacket, 'update2faEmail: verify'); + + final codePacket = await _api.sendRequest(Opcode.authCheckEmail, { + 'trackId': trackId, + 'verifyCode': code, + }); + _checkPacketError(codePacket, 'update2faEmail: code'); + + final payload = { + 'expectedCapabilities': [4], + 'trackId': trackId, + }; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'update2faEmail'); + return _processProfileUpdate(packet); + } + + Future remove2fa(String trackId) async { + _ensureOnline(); + final payload = { + 'expectedCapabilities': [5], + 'trackId': trackId, + 'remove2fa': true, + }; + final packet = await _api.sendRequest(Opcode.authSet2fa, payload); + _checkPacketError(packet, 'remove2fa'); + return _processProfileUpdate(packet); + } + + Future _processProfileUpdate(Packet packet) async { + _api.registerPushHandler(Opcode.notifProfile, (p) {}); + await for (final push in _api.pushStream.where( + (p) => p.opcode == Opcode.notifProfile, + )) { + 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()); + } + } + } + } + throw Exception('Не удалось получить обновлённый профиль'); + } + Future requestCode( String phone, { String language = 'ru', diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index ccd6191..f9bb88e 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -14,6 +14,7 @@ class ProfileData { final String country; final int accountStatus; final int updateTime; + final List? profileOptions; ProfileData({ required this.id, @@ -26,6 +27,7 @@ class ProfileData { required this.country, required this.accountStatus, required this.updateTime, + this.profileOptions, }); factory ProfileData.fromServerMap(Map contact) { @@ -44,6 +46,12 @@ class ProfileData { lastName = name['lastName'] as String?; } + final profileOptionsRaw = contact['profileOptions']; + List? profileOptions; + if (profileOptionsRaw is List) { + profileOptions = profileOptionsRaw.map((e) => e as int).toList(); + } + return ProfileData( id: contact['id'] as int, firstName: firstName, @@ -55,21 +63,31 @@ class ProfileData { country: (contact['country'] as String?) ?? '', accountStatus: (contact['accountStatus'] as int?) ?? 0, updateTime: (contact['updateTime'] as int?) ?? 0, + profileOptions: profileOptions, ); } factory ProfileData.fromDbRow(Map row) { + final profileOptionsStr = row['profile_options'] as String?; + List? profileOptions; + if (profileOptionsStr != null && profileOptionsStr.isNotEmpty) { + profileOptions = profileOptionsStr + .split(',') + .map((e) => int.parse(e.trim())) + .toList(); + } return ProfileData( id: row['id'] as int, - firstName: row['first_name'] as String, + firstName: (row['first_name'] as String?) ?? '', lastName: row['last_name'] as String?, - phone: row['phone'] as int, + phone: (row['phone'] as int?) ?? 0, photoId: row['photo_id'] as int?, baseUrl: row['base_url'] as String?, baseRawUrl: row['base_raw_url'] as String?, - country: row['country'] as String, - accountStatus: row['account_status'] as int, - updateTime: row['update_time'] as int, + country: (row['country'] as String?) ?? '', + accountStatus: (row['account_status'] as int?) ?? 0, + updateTime: (row['update_time'] as int?) ?? 0, + profileOptions: profileOptions, ); } @@ -84,6 +102,7 @@ class ProfileData { 'country': country, 'account_status': accountStatus, 'update_time': updateTime, + 'profile_options': profileOptions?.join(','), }; } @@ -119,7 +138,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 6, + version: 7, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -143,6 +162,11 @@ class AppDatabase { if (oldVersion < 6) { await db.execute(_messagesSchema); } + if (oldVersion < 7) { + await db.execute( + 'ALTER TABLE profile ADD COLUMN profile_options TEXT', + ); + } }, ); } @@ -160,7 +184,8 @@ class AppDatabase { country TEXT NOT NULL DEFAULT '', account_status INTEGER NOT NULL DEFAULT 0, update_time INTEGER NOT NULL DEFAULT 0, - is_active INTEGER NOT NULL DEFAULT 0 + is_active INTEGER NOT NULL DEFAULT 0, + profile_options TEXT ) '''); await db.execute(_syncStateSchema); @@ -318,6 +343,30 @@ class AppDatabase { }; } + static Future savePrivacyConfig( + int accountId, + String jsonConfig, + ) async { + final db = await _instance; + await db.insert('sync_state', { + 'account_id': accountId, + 'key': 'privacy_config', + 'value': jsonConfig, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + static Future getPrivacyConfig(int accountId) async { + final db = await _instance; + final rows = await db.query( + 'sync_state', + where: 'account_id = ? AND key = ?', + whereArgs: [accountId, 'privacy_config'], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['value'] as String; + } + static Future close() async { await _db?.close(); _db = null; diff --git a/lib/core/transport/sender.dart b/lib/core/transport/sender.dart index 8ef642f..69e8c5d 100644 --- a/lib/core/transport/sender.dart +++ b/lib/core/transport/sender.dart @@ -2,7 +2,6 @@ import '../protocol/packet.dart'; import '../utils/logger.dart'; import 'connection.dart'; -/// Упаковывает и отправляет пакеты, ведёт счётчик seq. class PacketSender { int _seq = 0; @@ -13,7 +12,6 @@ class PacketSender { return _seq; } - /// Отправляет пакет, возвращает присвоенный seq. int send(Connection connection, int opcode, Map payload) { final seq = _nextSeq(); final data = packPacket(opcode, payload, seq: seq); diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart new file mode 100644 index 0000000..dca05e4 --- /dev/null +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -0,0 +1,1488 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show accountModule; +import '../../../backend/modules/account.dart' show TwoFactorDetails; +import '../../../core/storage/app_database.dart'; +import '../../widgets/custom_notification.dart'; + +class PasswordEntryScreen extends StatefulWidget { + const PasswordEntryScreen({super.key}); + + @override + State createState() => _PasswordEntryScreenState(); +} + +class _PasswordEntryScreenState extends State { + bool _isLoading = true; + bool _is2faEnabled = false; + String? _email; + String? _hint; + + @override + void initState() { + super.initState(); + _check2faStatus(); + } + + Future _check2faStatus() async { + try { + final profile = await AppDatabase.loadActiveProfile(); + if (mounted) { + setState(() { + _is2faEnabled = profile?.profileOptions?.contains(2) ?? false; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка: $e'); + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_isLoading) { + return Scaffold( + backgroundColor: cs.surface, + body: Center(child: CircularProgressIndicator(color: cs.primary)), + ); + } + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildMainSection(cs), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppBar(BuildContext context, ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Text( + 'Пароль для входа', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ], + ), + ); + } + + Widget _buildMainSection(ColorScheme cs) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _is2faEnabled ? Symbols.lock : Symbols.lock_open, + color: cs.primary, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _is2faEnabled + ? 'Пароль установлен' + : 'Пароль не установлен', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _is2faEnabled + ? 'Используется для дополнительной защиты' + : 'Двухфакторная аутентификация', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + ), + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Symbols.settings, + label: _is2faEnabled ? 'Изменить пароль' : 'Установить пароль', + isLast: _is2faEnabled, + onTap: () => _navigateToPasswordSetup(context, cs), + ), + if (_is2faEnabled) ...[ + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Icons.email_outlined, + label: 'Изменить почту', + isLast: false, + onTap: () => _navigateToEmailChange(context, cs), + ), + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + _buildActionRow( + cs, + icon: Icons.delete_outline, + label: 'Удалить пароль', + isLast: true, + textColor: cs.error, + onTap: () => _showRemoveConfirmation(context, cs), + ), + ], + ], + ), + ); + } + + Widget _buildActionRow( + ColorScheme cs, { + required IconData icon, + required String label, + required bool isLast, + required VoidCallback onTap, + Color? textColor, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + icon, + color: textColor ?? cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: TextStyle( + color: textColor ?? cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + ], + ); + } + + void _navigateToPasswordSetup(BuildContext context, ColorScheme cs) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const TwoFactorSetupScreen()), + ); + } + + void _navigateToEmailChange(BuildContext context, ColorScheme cs) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorEmailChangeScreen(), + ), + ); + } + + void _showRemoveConfirmation(BuildContext context, ColorScheme cs) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text('Удалить пароль?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы уверены, что хотите удалить пароль для входа? Это ослабит защиту вашего аккаунта.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorRemoveScreen(), + ), + ); + }, + child: Text('Удалить', style: TextStyle(color: cs.error)), + ), + ], + ), + ); + } +} + +class TwoFactorSetupScreen extends StatefulWidget { + const TwoFactorSetupScreen({super.key}); + + @override + State createState() => _TwoFactorSetupScreenState(); +} + +class _TwoFactorSetupScreenState extends State { + final _passwordController = TextEditingController(); + final _hintController = TextEditingController(); + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + + int _step = 0; + bool _isLoading = false; + String? _trackId; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _hintController.dispose(); + _emailController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _nextStep() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + switch (_step) { + case 0: + final trackId = await accountModule.create2faTrack(); + setState(() { + _trackId = trackId; + _step = 1; + }); + break; + case 1: + if (_passwordController.text.length < 6) { + setState( + () => _errorMessage = 'Пароль должен быть минимум 6 символов', + ); + break; + } + await accountModule.set2faPassword( + _trackId!, + _passwordController.text, + ); + setState(() => _step = 2); + break; + case 2: + if (_hintController.text.isNotEmpty) { + await accountModule.set2faHint(_trackId!, _hintController.text); + } + setState(() => _step = 3); + break; + case 3: + if (!_emailController.text.contains('@')) { + setState(() => _errorMessage = 'Введите корректный email'); + break; + } + await accountModule.verify2faEmail(_trackId!, _emailController.text); + setState(() => _step = 4); + break; + case 4: + if (_codeController.text.length != 6) { + setState(() => _errorMessage = 'Введите 6-значный код'); + break; + } + await accountModule.verify2faCode(_trackId!, _codeController.text); + await accountModule.confirm2fa( + trackId: _trackId!, + password: _passwordController.text, + hint: _hintController.text.isEmpty ? null : _hintController.text, + ); + if (mounted) { + showCustomNotification(context, 'Пароль установлен'); + Navigator.popUntil( + context, + (route) => + route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + break; + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Установка пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: _buildStepContent(cs), + ); + } + + Widget _buildStepContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStepIndicator(cs), + const SizedBox(height: 24), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Symbols.error, color: cs.error, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle( + color: cs.onErrorContainer, + fontSize: 14, + ), + ), + ), + ], + ), + ), + _buildCurrentStep(cs), + 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: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), + ), + ), + ], + ), + ); + } + + Widget _buildStepIndicator(ColorScheme cs) { + final steps = ['Пароль', 'Подсказка', 'Почта', 'Код', 'Готово']; + return Row( + children: List.generate(steps.length, (index) { + final isActive = index <= _step; + final isCurrent = index == _step; + return Expanded( + child: Column( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive ? cs.primary : cs.surfaceContainerHighest, + ), + child: Center( + child: isActive + ? Icon(Symbols.check, color: cs.onPrimary, size: 16) + : Text( + '${index + 1}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + steps[index], + style: TextStyle( + color: isCurrent ? cs.primary : cs.onSurfaceVariant, + fontSize: 11, + fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ); + }), + ); + } + + Widget _buildCurrentStep(ColorScheme cs) { + switch (_step) { + case 0: + return _buildPasswordField(cs); + case 1: + return _buildPasswordConfirmField(cs); + case 2: + return _buildHintField(cs); + case 3: + return _buildEmailField(cs); + case 4: + return _buildCodeField(cs); + default: + return const SizedBox(); + } + } + + Widget _buildPasswordField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Придумайте пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Минимум 6 символов', + 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, + ), + ), + ), + ], + ); + } + + Widget _buildPasswordConfirmField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подтвердите пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Введите пароль ещё раз', + 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, + ), + ), + ), + ], + ); + } + + Widget _buildHintField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Подсказка для пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Необязательно', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _hintController, + decoration: InputDecoration( + hintText: 'Введите подсказку (необязательно)', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildEmailField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Привяжите email', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Для восстановления пароля', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'example@mail.ru', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } + + Widget _buildCodeField(ColorScheme cs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Введите код', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Код отправлен на ${_emailController.text}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 16), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: '000000', + counterText: '', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } +} + +class TwoFactorManageScreen extends StatefulWidget { + const TwoFactorManageScreen({super.key}); + + @override + State createState() => _TwoFactorManageScreenState(); +} + +class _TwoFactorManageScreenState extends State { + final _passwordController = TextEditingController(); + bool _isLoading = false; + bool _isAuthenticated = false; + String? _trackId; + TwoFactorDetails? _details; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _authenticate() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + _trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(_trackId!, _passwordController.text); + final details = await accountModule.get2faDetails(_trackId!); + setState(() { + _isAuthenticated = true; + _details = details; + }); + } catch (e) { + setState(() => _errorMessage = 'Неверный пароль'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Управление паролем', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: _isAuthenticated ? _buildManageContent(cs) : _buildAuthContent(cs), + ); + } + + Widget _buildAuthContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Введите текущий пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + 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: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Продолжить'), + ), + ), + ], + ), + ); + } + + Widget _buildManageContent(ColorScheme cs) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + Icon(Symbols.lock, color: cs.primary, size: 48), + const SizedBox(height: 12), + Text( + 'Пароль установлен', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + if (_details?.email != null) ...[ + const SizedBox(height: 4), + Text( + _details!.email!, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + ], + if (_details?.hint != null && _details!.hint!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Подсказка: ${_details!.hint}', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TwoFactorPasswordChangeScreen(), + ), + ); + }, + icon: const Icon(Symbols.edit), + label: const Text('Изменить пароль'), + style: OutlinedButton.styleFrom( + foregroundColor: cs.primary, + side: BorderSide(color: cs.outline), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ); + } +} + +class TwoFactorPasswordChangeScreen extends StatefulWidget { + const TwoFactorPasswordChangeScreen({super.key}); + + @override + State createState() => + _TwoFactorPasswordChangeScreenState(); +} + +class _TwoFactorPasswordChangeScreenState + extends State { + final _passwordController = TextEditingController(); + final _hintController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _hintController.dispose(); + super.dispose(); + } + + Future _changePassword() async { + if (_passwordController.text.length < 6) { + setState(() => _errorMessage = 'Пароль должен быть минимум 6 символов'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(trackId, _passwordController.text); + await accountModule.update2faPassword( + trackId: trackId, + newPassword: _passwordController.text, + hint: _hintController.text.isEmpty ? null : _hintController.text, + ); + if (mounted) { + showCustomNotification(context, 'Пароль изменён'); + Navigator.popUntil( + context, + (route) => route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Изменить пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Новый пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Введите новый пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Подсказка', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _hintController, + decoration: InputDecoration( + hintText: 'Введите подсказку (необязательно)', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + 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: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Сохранить'), + ), + ), + ], + ), + ), + ); + } +} + +class TwoFactorEmailChangeScreen extends StatefulWidget { + const TwoFactorEmailChangeScreen({super.key}); + + @override + State createState() => + _TwoFactorEmailChangeScreenState(); +} + +class _TwoFactorEmailChangeScreenState + extends State { + final _passwordController = TextEditingController(); + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + int _step = 0; + bool _isLoading = false; + String? _trackId; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + _emailController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _nextStep() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + switch (_step) { + case 0: + _trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword( + _trackId!, + _passwordController.text, + ); + setState(() => _step = 1); + break; + case 1: + if (!_emailController.text.contains('@')) { + setState(() => _errorMessage = 'Введите корректный email'); + break; + } + await accountModule.verify2faEmail(_trackId!, _emailController.text); + setState(() => _step = 2); + break; + case 2: + if (_codeController.text.length != 6) { + setState(() => _errorMessage = 'Введите 6-значный код'); + break; + } + await accountModule.verify2faCode(_trackId!, _codeController.text); + await accountModule.update2faEmail( + trackId: _trackId!, + email: _emailController.text, + code: _codeController.text, + ); + if (mounted) { + showCustomNotification(context, 'Почта изменена'); + Navigator.popUntil( + context, + (route) => + route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + break; + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Изменить почту', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_step == 0) ...[ + Text( + 'Введите текущий пароль', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + 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, + ), + ), + ), + ] else ...[ + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + if (_step == 1) ...[ + Text( + 'Новая почта', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'example@mail.ru', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ] else ...[ + Text( + 'Введите код', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Код отправлен на ${_emailController.text}', + style: TextStyle(color: cs.onSurfaceVariant), + ), + const SizedBox(height: 16), + TextField( + controller: _codeController, + keyboardType: TextInputType.number, + maxLength: 6, + decoration: InputDecoration( + hintText: '000000', + counterText: '', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ], + 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: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), + ), + ), + ], + ), + ), + ); + } +} + +class TwoFactorRemoveScreen extends StatefulWidget { + const TwoFactorRemoveScreen({super.key}); + + @override + State createState() => _TwoFactorRemoveScreenState(); +} + +class _TwoFactorRemoveScreenState extends State { + final _passwordController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _remove2fa() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final trackId = await accountModule.enter2faPanel(); + await accountModule.check2faPassword(trackId, _passwordController.text); + await accountModule.remove2fa(trackId); + if (mounted) { + showCustomNotification(context, 'Пароль удалён'); + Navigator.popUntil( + context, + (route) => route.isFirst || route.settings.name == 'SecurityScreen', + ); + } + } catch (e) { + setState(() => _errorMessage = e.toString()); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + elevation: 0, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + title: Text( + 'Удаление пароля', + style: TextStyle( + color: cs.onSurface, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Symbols.warning, color: cs.error), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Внимание! После удаления пароля защита вашего аккаунта ослабнет.', + style: TextStyle(color: cs.onSurface), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + 'Введите пароль для подтверждения', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + if (_errorMessage != null) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _errorMessage!, + style: TextStyle(color: cs.onErrorContainer), + ), + ), + TextField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: 'Пароль', + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + 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: _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onError, + ), + ) + : const Text('Удалить пароль'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index a2c84d0..676396e 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -1,6 +1,12 @@ +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show accountModule; +import '../../../backend/modules/account.dart' + show PrivacyConfig, BlockedContact; +import '../../../core/storage/app_database.dart'; import '../../widgets/custom_notification.dart'; +import 'password_entry_screen.dart'; class SecurityScreen extends StatefulWidget { const SecurityScreen({super.key}); @@ -9,8 +15,73 @@ class SecurityScreen extends StatefulWidget { State createState() => _SecurityScreenState(); } -class _SecurityScreenState extends State { - bool _safeMode = false; +class _SecurityScreenState extends State + with SingleTickerProviderStateMixin { + bool _isLoading = true; + bool _isSaving = false; + bool _is2faEnabled = false; + PrivacyConfig? _privacyConfig; + List _blockedContacts = []; + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + _loadData(); + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + Future _loadData() async { + try { + final results = await Future.wait([ + accountModule.getPrivacyConfig(), + accountModule.getBlockedContacts(), + AppDatabase.loadActiveProfile(), + ]); + 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; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка загрузки: $e'); + setState(() => _isLoading = false); + } + } + } + + Future _updateSetting(String key, dynamic value) async { + if (_isSaving) return; + setState(() => _isSaving = true); + try { + final newConfig = await accountModule.updatePrivacyConfig({key: value}); + if (mounted) { + setState(() => _privacyConfig = newConfig); + } + } catch (e) { + if (mounted) { + showCustomNotification(context, 'Ошибка сохранения: $e'); + } + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } @override Widget build(BuildContext context) { @@ -20,53 +91,100 @@ class _SecurityScreenState extends State { backgroundColor: cs.surface, body: SafeArea( bottom: false, - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter(child: _buildAppBar(context, cs)), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildTopSection(cs), + child: _isLoading + ? _buildShimmer(cs) + : CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _buildAppBar(context, cs)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildTopSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: _buildPrivacySettings(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), + child: _buildInfoLabel(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildConfidentialSection(cs), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), + child: _buildBlacklistSection(cs), + ), + ), + ], ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: _buildSafeModeSection(cs), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), - child: _buildInfoLabel(cs), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildOnlineSection(cs), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), - child: _buildBlacklistSection(cs), - ), - ), - ], - ), ), ); } + Widget _buildShimmer(ColorScheme cs) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + children: [ + _buildAppBar(context, cs), + _buildShimmerSection(cs, height: 104), + const SizedBox(height: 12), + _buildShimmerSection(cs, height: 280), + const SizedBox(height: 20), + _buildShimmerSection(cs, height: 220), + const SizedBox(height: 12), + _buildShimmerSection(cs, height: 120), + ], + ), + ); + } + + Widget _buildShimmerSection(ColorScheme cs, {required double height}) { + return AnimatedBuilder( + animation: _shimmerController, + builder: (context, child) { + final opacity = 0.3 + 0.2 * sin(_shimmerController.value * pi * 2); + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + height: height, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ); + }, + ); + } + Widget _buildAppBar(BuildContext context, ColorScheme cs) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Row( children: [ IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface, size: 24, weight: 400), + icon: Icon( + Symbols.arrow_back, + color: cs.onSurface, + size: 24, + weight: 400, + ), onPressed: () => Navigator.pop(context), ), const SizedBox(width: 4), @@ -79,11 +197,37 @@ class _SecurityScreenState extends State { fontFamily: 'Outfit', ), ), + const Spacer(), + if (_isSaving) + Padding( + padding: const EdgeInsets.only(right: 16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.primary, + ), + ), + ), ], ), ); } + String _getPrivacyLabel(String value) { + switch (value) { + case 'ALL': + return 'Все'; + case 'CONTACTS': + return 'Мои контакты'; + case 'NONE': + return 'Никто'; + default: + return value; + } + } + Widget _buildTopSection(ColorScheme cs) { return Container( decoration: BoxDecoration( @@ -92,19 +236,14 @@ class _SecurityScreenState extends State { ), child: Column( children: [ - _buildNavRow( - cs, - icon: Symbols.key, - label: 'Пароль для входа', - subtitle: 'Отключён', - trailing: _buildWarningBadge(cs), - isLast: false, - ), + _buildPasswordRow(cs), _buildNavRow( cs, icon: Symbols.shield, label: 'Семейная защита', - subtitle: 'Отключена', + subtitle: _privacyConfig?.familyProtection == 'ON' + ? 'Включена' + : 'Отключена', isLast: true, ), ], @@ -112,7 +251,82 @@ class _SecurityScreenState extends State { ); } - Widget _buildSafeModeSection(ColorScheme cs) { + Widget _buildPasswordRow(ColorScheme cs) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PasswordEntryScreen(), + ), + ); + }, + borderRadius: BorderRadius.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), + child: Row( + children: [ + Icon( + Symbols.key, + 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( + _is2faEnabled ? 'Включён' : 'Отключён', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + _buildWarningBadge(cs), + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildPrivacySettings(ColorScheme cs) { + final isSafeMode = _privacyConfig?.safeMode ?? false; return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -124,7 +338,12 @@ class _SecurityScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( children: [ - Icon(Symbols.lock, color: cs.onSurfaceVariant, size: 22, weight: 400), + Icon( + Symbols.lock, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), const SizedBox(width: 16), Expanded( child: Column( @@ -140,7 +359,7 @@ class _SecurityScreenState extends State { ), const SizedBox(height: 2), Text( - 'Доступно только в мобильном приложении', + 'Скрывает личную информацию', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -150,32 +369,366 @@ class _SecurityScreenState extends State { ), ), Switch( - value: _safeMode, - onChanged: (v) => setState(() => _safeMode = v), + value: isSafeMode, + onChanged: (v) => showCustomNotification( + context, + 'Изменение настроек пока недоступно', + ), ), ], ), ), - if (_safeMode) ...[ + if (isSafeMode) ...[ Padding( padding: const EdgeInsets.only(left: 58), - child: Divider(height: 1, thickness: 1, color: cs.outlineVariant.withValues(alpha: 0.35)), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + _buildSubRow( + cs, + label: 'Найти меня по номеру', + value: _getPrivacyLabel(_privacyConfig?.searchByPhone ?? 'ALL'), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Кто может мне звонить', + value: _getPrivacyLabel( + _privacyConfig?.incomingCall ?? 'CONTACTS', + ), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Кто может приглашать в чаты', + value: _getPrivacyLabel( + _privacyConfig?.chatsInvite ?? 'CONTACTS', + ), + isLast: false, + ), + _buildSubRow( + cs, + label: 'Показывать контакт', + value: _privacyConfig?.contentLevelAccess == true + ? 'Безопасный' + : 'Весь', + isLast: true, + ), + ], + if (!isSafeMode) ...[ + Padding( + padding: const EdgeInsets.only(left: 20), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.phone, + label: 'Кто может мне звонить', + value: _getPrivacyLabel( + _privacyConfig?.incomingCall ?? 'CONTACTS', + ), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Кто может мне звонить', + currentValue: _privacyConfig?.incomingCall ?? 'CONTACTS', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('INCOMING_CALL', value), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.group, + label: 'Кто может приглашать в чаты', + value: _getPrivacyLabel( + _privacyConfig?.chatsInvite ?? 'CONTACTS', + ), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Кто может приглашать в чаты', + currentValue: _privacyConfig?.chatsInvite ?? 'CONTACTS', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('CHATS_INVITE', value), + ), + ), + _buildOptionRow( + cs, + icon: Symbols.contact_phone, + label: 'Найти меня по номеру', + value: _getPrivacyLabel(_privacyConfig?.searchByPhone ?? 'ALL'), + isLast: false, + onTap: () => _showOptionSheet( + context, + cs, + title: 'Найти меня по номеру', + currentValue: _privacyConfig?.searchByPhone ?? 'ALL', + options: const [('ALL', 'Все'), ('CONTACTS', 'Мои контакты')], + onSelect: (value) => _updateSetting('SEARCH_BY_PHONE', value), + ), + ), + _buildOptionRow( + cs, + icon: Icons.visibility_off_outlined, + label: 'Видеть статус «в сети»', + value: _privacyConfig?.hidden == true ? 'Никто' : 'Мои контакты', + isLast: true, + onTap: () => _showHiddenStatusSheet(context, cs), ), - _buildSubRow(cs, label: 'Найти меня по номеру', value: 'Могут все', isLast: false), - _buildSubRow(cs, label: 'Позвонить', value: 'Могут все', isLast: false), - _buildSubRow(cs, label: 'Пригласить в чат', value: 'Могут контакты', isLast: false), - _buildSubRow(cs, label: 'Показывать контент', value: 'Весь', isLast: true), ], ], ), ); } + void _showOptionSheet( + BuildContext context, + ColorScheme cs, { + required String title, + required String currentValue, + required List<(String, String)> options, + required void Function(String) onSelect, + }) { + showModalBottomSheet( + context: context, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ...options.map((option) { + final isSelected = option.$1 == currentValue; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.pop(context); + onSelect(option.$1); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + child: Row( + children: [ + Expanded( + child: Text( + option.$2, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + ), + ), + ), + if (isSelected) + Icon(Symbols.check, color: cs.primary, size: 20), + ], + ), + ), + ), + ); + }), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + void _showHiddenStatusSheet(BuildContext context, ColorScheme cs) { + final currentValue = _privacyConfig?.hidden == true ? 'NONE' : 'CONTACTS'; + + if (currentValue == 'NONE') { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы не сможете видеть статусы посещения других пользователей.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _updateSetting('HIDDEN', false); + }, + child: Text('Да', style: TextStyle(color: cs.primary)), + ), + ], + ), + ); + return; + } + + showModalBottomSheet( + context: context, + backgroundColor: cs.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + 'Видеть статус «в сети»', + style: TextStyle( + color: cs.onSurface, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _buildOptionSheetItem( + cs, + 'Мои контакты', + currentValue == 'CONTACTS', + () { + Navigator.pop(context); + _updateSetting('HIDDEN', false); + }, + ), + _buildOptionSheetItem(cs, 'Никто', currentValue == 'NONE', () { + Navigator.pop(context); + _showHiddenStatusConfirmDialog(context, cs); + }, isLast: true), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + } + + void _showHiddenStatusConfirmDialog(BuildContext context, ColorScheme cs) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text('Вы уверены?', style: TextStyle(color: cs.onSurface)), + content: Text( + 'Вы не сможете видеть статусы посещения других пользователей.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Отмена', style: TextStyle(color: cs.primary)), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _updateSetting('HIDDEN', true); + }, + child: Text('Да', style: TextStyle(color: cs.primary)), + ), + ], + ), + ); + } + + Widget _buildOptionSheetItem( + ColorScheme cs, + String label, + bool isSelected, + VoidCallback onTap, { + bool isLast = false, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle(color: cs.onSurface, fontSize: 16), + ), + ), + if (isSelected) + Icon(Symbols.check, color: cs.primary, size: 20), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 20), + child: Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.3), + ), + ), + ], + ); + } + Widget _buildInfoLabel(ColorScheme cs) { return Padding( padding: const EdgeInsets.only(left: 4, bottom: 0), child: Text( - 'ИНФОРМАЦИЯ', + 'КОНФИДЕНЦИАЛЬНОСТЬ', style: TextStyle( color: cs.onSurfaceVariant.withValues(alpha: 0.6), fontSize: 12, @@ -186,24 +739,53 @@ class _SecurityScreenState extends State { ); } - Widget _buildOnlineSection(ColorScheme cs) { + Widget _buildConfidentialSection(ColorScheme cs) { return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), ), - child: _buildNavRow( - cs, - icon: null, - label: 'Видеть статус «в сети»', - value: 'Никто', - isLast: true, - noIcon: true, + child: Column( + children: [ + _buildSwitchRow( + cs, + icon: Symbols.description, + label: 'Галочки «Прочитано»', + value: _privacyConfig?.showReadMark ?? true, + isLast: false, + onChanged: (v) => _updateSetting('SHOW_READ_MARK', v), + ), + _buildSwitchRow( + cs, + icon: Symbols.keyboard_alt, + label: 'Альтернативная клавиатура', + value: _privacyConfig?.altKeyboard ?? false, + isLast: false, + onChanged: (v) => _updateSetting('ALT_KEYBOARD', v), + ), + _buildSwitchRow( + cs, + icon: Symbols.warning, + label: 'Принимать опасные файлы', + value: _privacyConfig?.unsafeFiles ?? true, + isLast: false, + onChanged: (v) => _updateSetting('UNSAFE_FILES', v), + ), + _buildSwitchRow( + cs, + icon: Icons.mic_none_outlined, + label: 'Транскрибация аудио', + value: _privacyConfig?.audioTranscriptionEnabled ?? true, + isLast: true, + onChanged: (v) => _updateSetting('AUDIO_TRANSCRIPTION_ENABLED', v), + ), + ], ), ); } Widget _buildBlacklistSection(ColorScheme cs) { + final count = _blockedContacts.length; return Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, @@ -212,12 +794,22 @@ class _SecurityScreenState extends State { child: Material( color: Colors.transparent, child: InkWell( - onTap: () => showCustomNotification(context, 'Чёрный список'), + onTap: () => showCustomNotification( + context, + 'Чёрный список: $count контактов', + ), borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( children: [ + Icon( + Symbols.block, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -232,7 +824,7 @@ class _SecurityScreenState extends State { ), const SizedBox(height: 2), Text( - 'Список тех, кто не может вам писать, звонить и добавлять в чаты', + '$count ${_getBlockedCountText(count)}', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, @@ -242,7 +834,12 @@ class _SecurityScreenState extends State { ), ), const SizedBox(width: 8), - Icon(Symbols.chevron_right, color: cs.outline, size: 20, weight: 400), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), ], ), ), @@ -251,15 +848,22 @@ class _SecurityScreenState extends State { ); } + String _getBlockedCountText(int count) { + if (count == 0) return 'контактов'; + final mod = count % 10; + if (mod == 1 && count != 11) return 'контакт'; + if (mod >= 2 && mod <= 4 && (count < 10 || count > 20)) return 'контакта'; + return 'контактов'; + } + Widget _buildNavRow( ColorScheme cs, { - required IconData? icon, + required IconData icon, required String label, String? subtitle, String? value, Widget? trailing, required bool isLast, - bool noIcon = false, }) { return Column( children: [ @@ -274,10 +878,8 @@ class _SecurityScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17), child: Row( children: [ - if (!noIcon) ...[ - Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), - const SizedBox(width: 16), - ], + Icon(icon, color: cs.onSurfaceVariant, size: 22, weight: 400), + const SizedBox(width: 16), Expanded( child: subtitle != null ? Column( @@ -320,7 +922,12 @@ class _SecurityScreenState extends State { ), if (trailing != null) trailing, const SizedBox(width: 4), - Icon(Symbols.chevron_right, color: cs.outline, size: 20, weight: 400), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), ], ), ), @@ -329,7 +936,73 @@ class _SecurityScreenState extends State { if (!isLast) Padding( padding: const EdgeInsets.only(left: 58), - child: Divider(height: 1, thickness: 1, color: cs.outlineVariant.withValues(alpha: 0.35)), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + + Widget _buildOptionRow( + ColorScheme cs, { + required IconData icon, + required String label, + required String value, + required bool isLast, + required VoidCallback onTap, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + 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( + value, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(width: 4), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 20, + weight: 400, + ), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), ), ], ); @@ -365,7 +1038,12 @@ class _SecurityScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), ), const SizedBox(width: 4), - Icon(Symbols.chevron_right, color: cs.outline, size: 18, weight: 400), + Icon( + Symbols.chevron_right, + color: cs.outline, + size: 18, + weight: 400, + ), ], ), ), @@ -383,15 +1061,69 @@ class _SecurityScreenState extends State { ); } + Widget _buildSwitchRow( + ColorScheme cs, { + required IconData icon, + required String label, + required bool value, + required bool isLast, + required void Function(bool) onChanged, + }) { + return Column( + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => onChanged(!value), + borderRadius: isLast + ? const BorderRadius.vertical(bottom: Radius.circular(20)) + : BorderRadius.zero, + 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: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.only(left: 58), + child: Divider( + height: 1, + thickness: 1, + color: cs.outlineVariant.withValues(alpha: 0.35), + ), + ), + ], + ); + } + Widget _buildWarningBadge(ColorScheme cs) { return Container( width: 22, height: 22, - decoration: BoxDecoration( - color: cs.error, - shape: BoxShape.circle, + decoration: BoxDecoration(color: cs.error, shape: BoxShape.circle), + child: Icon( + Symbols.priority_high, + color: cs.onError, + size: 14, + weight: 700, ), - child: Icon(Symbols.priority_high, color: cs.onError, size: 14, weight: 700), ); } } diff --git a/pubspec.lock b/pubspec.lock index 66aac21..62a257f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: From bdd7461fb3fb7a0e182fd205d62c90ae5c0d12ba Mon Sep 17 00:00:00 2001 From: sergejwinston Date: Mon, 6 Apr 2026 23:11:26 +0700 Subject: [PATCH 37/59] Add AGENTS.md to .gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 14601b5..0d6d7ea 100644 --- a/.gitignore +++ b/.gitignore @@ -125,4 +125,7 @@ app.*.symbols !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -!/dev/ci/**/Gemfile.lock \ No newline at end of file +!/dev/ci/**/Gemfile.lock + + +AGENTS.md \ No newline at end of file From 8b680f277be1e24749cddf084cc9ada3cdea26e1 Mon Sep 17 00:00:00 2001 From: sergejwinston Date: Tue, 7 Apr 2026 02:51:34 +0700 Subject: [PATCH 38/59] feat(auth): integrate SmartAuth for SMS code auto-fill and update localization strings --- android/gradle.properties | 4 ++ .../auth/code_confirmation_screen.dart | 65 +++++++++++++++++-- lib/frontend/screens/auth/login_screen.dart | 9 --- lib/l10n/app_en.arb | 15 ++--- lib/l10n/app_localizations.dart | 20 ++---- lib/l10n/app_localizations_en.dart | 18 +++-- lib/l10n/app_localizations_ru.dart | 9 +-- lib/l10n/app_ru.arb | 6 +- pubspec.lock | 8 +++ pubspec.yaml | 1 + 10 files changed, 101 insertions(+), 54 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index 492b2f1..398cde8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,3 +2,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true kotlin.incremental=false dev.steenbakker.mobile_scanner.useUnbundled=true +systemProp.socksProxyHost=127.0.0.1 +systemProp.socksProxyPort=10808 +systemProp.https.proxyHost=127.0.0.1 +systemProp.https.proxyPort=10808 diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 3840751..21a2194 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:smart_auth/smart_auth.dart'; import '../chats/chat_list_screen.dart'; import 'password_2fa_screen.dart'; import '../../../main.dart'; @@ -26,9 +28,11 @@ class _CodeConfirmationScreenState extends State with TickerProviderStateMixin { final TextEditingController _codeController = TextEditingController(); final FocusNode _focusNode = FocusNode(); + final SmartAuth _smartAuth = SmartAuth.instance; int _timerSeconds = 30; Timer? _timer; Timer? _errorTimer; + bool _isListeningSmsConsent = false; String? _errorMessage; late AnimationController _shakeController; @@ -41,6 +45,7 @@ class _CodeConfirmationScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); + _startSmsCodeListener(); _shakeController = AnimationController( vsync: this, @@ -56,10 +61,63 @@ class _CodeConfirmationScreenState extends State ]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear)); } + Future _startSmsCodeListener() async { + if (defaultTargetPlatform != TargetPlatform.android) return; + if (_isListeningSmsConsent) return; + + _isListeningSmsConsent = true; + final result = await _smartAuth.getSmsWithUserConsentApi(matcher: r'\d{6}'); + _isListeningSmsConsent = false; + + if (!mounted || !result.hasData) return; + + final receivedCode = result.data?.code; + if (receivedCode == null || receivedCode.isEmpty) return; + + _applyAutoFillCode(receivedCode); + } + + Future _restartSmsCodeListener() async { + if (defaultTargetPlatform != TargetPlatform.android) return; + await _smartAuth.removeUserConsentApiListener(); + _isListeningSmsConsent = false; + await _startSmsCodeListener(); + } + + void _handleCodeChanged(String value) { + setState(() { + if (_errorMessage != null) { + _errorMessage = null; + } + }); + if (value.length == 6) { + _verifyCode(); + } + } + + void _applyAutoFillCode(String rawCode) { + final digitsOnly = rawCode.replaceAll(RegExp(r'\D'), ''); + if (digitsOnly.isEmpty) return; + + final nextCode = digitsOnly.length <= 6 + ? digitsOnly + : digitsOnly.substring(0, 6); + + if (_codeController.text == nextCode) return; + + _codeController.value = TextEditingValue( + text: nextCode, + selection: TextSelection.collapsed(offset: nextCode.length), + ); + + _handleCodeChanged(nextCode); + } + @override void dispose() { _timer?.cancel(); _errorTimer?.cancel(); + unawaited(_smartAuth.removeUserConsentApiListener()); _shakeController.dispose(); _codeController.dispose(); _focusNode.dispose(); @@ -92,6 +150,7 @@ class _CodeConfirmationScreenState extends State void _resendCode() { if (_timerSeconds == 0) { _startTimer(); + unawaited(_restartSmsCodeListener()); } } @@ -206,11 +265,7 @@ class _CodeConfirmationScreenState extends State FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6), ], - onChanged: (value) { - if (hasError) setState(() => _errorMessage = null); - setState(() {}); - if (value.length == 6) _verifyCode(); - }, + onChanged: _handleCodeChanged, ), ), ), diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 579f250..08f74e8 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -795,15 +795,6 @@ class _LoginScreenState extends State { height: 1.4, ), children: [ - TextSpan( - text: l10n.loginTermsIntro, - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ), TextSpan( text: l10n.loginTermsLink, style: GoogleFonts.inter( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aa424d8..7ec3f63 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,23 +1,22 @@ { "@@locale": "en", - "loginTitle": "Sign in to Komet", + "loginTitle": "Log in to Komet", "loginSubtitle": "Check your country code and enter your\nphone number.", "loginCountry": "Country", "loginPhoneNumber": "Phone number", "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Other sign-in methods", - "loginTermsIntro": "By continuing, you agree to \n", - "loginTermsLink": "the terms of use", - "loginTermsOfUse": "Terms of use", + "loginTermsLink": "Terms of Use «Komet»", + "loginTermsOfUse": "Terms of Use «Komet»", "loginConfirmPhoneTitle": "Is this the correct number?", "loginEdit": "Change", "loginDone": "Done", - "loginReadTermsNotification": "Please read the terms of use first", + "loginReadTermsNotification": "Please read the Terms of Use «Komet»", "loginSpoofRedacted": "Spoof redaction", "loginProxy": "Proxy", - "loginSignInWithQr": "Sign in with QR code", - "loginSignInWithToken": "Sign in with token", - "loginSignInWithSessionFile": "Sign in with session file", + "loginSignInWithQr": "Log in with QR code", + "loginSignInWithToken": "Log in with token", + "loginSignInWithSessionFile": "Log in with session file", "loginLanguage": "Language", "languageNameRu": "Русский", "languageNameEn": "English", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b0614d5..67816f8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -101,7 +101,7 @@ abstract class AppLocalizations { /// No description provided for @loginTitle. /// /// In en, this message translates to: - /// **'Sign in to Komet'** + /// **'Log in to Komet'** String get loginTitle; /// No description provided for @loginSubtitle. @@ -134,22 +134,16 @@ abstract class AppLocalizations { /// **'Other sign-in methods'** String get loginOtherSignInMethods; - /// No description provided for @loginTermsIntro. - /// - /// In en, this message translates to: - /// **'By continuing, you agree to \n'** - String get loginTermsIntro; - /// No description provided for @loginTermsLink. /// /// In en, this message translates to: - /// **'the terms of use'** + /// **'Terms of Use «Komet»'** String get loginTermsLink; /// No description provided for @loginTermsOfUse. /// /// In en, this message translates to: - /// **'Terms of use'** + /// **'Terms of Use «Komet»'** String get loginTermsOfUse; /// No description provided for @loginConfirmPhoneTitle. @@ -173,7 +167,7 @@ abstract class AppLocalizations { /// No description provided for @loginReadTermsNotification. /// /// In en, this message translates to: - /// **'Please read the terms of use first'** + /// **'Please read the Terms of Use «Komet»'** String get loginReadTermsNotification; /// No description provided for @loginSpoofRedacted. @@ -191,19 +185,19 @@ abstract class AppLocalizations { /// No description provided for @loginSignInWithQr. /// /// In en, this message translates to: - /// **'Sign in with QR code'** + /// **'Log in with QR code'** String get loginSignInWithQr; /// No description provided for @loginSignInWithToken. /// /// In en, this message translates to: - /// **'Sign in with token'** + /// **'Log in with token'** String get loginSignInWithToken; /// No description provided for @loginSignInWithSessionFile. /// /// In en, this message translates to: - /// **'Sign in with session file'** + /// **'Log in with session file'** String get loginSignInWithSessionFile; /// No description provided for @loginLanguage. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5ddbe9b..3f6e67e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override - String get loginTitle => 'Sign in to Komet'; + String get loginTitle => 'Log in to Komet'; @override String get loginSubtitle => @@ -28,13 +28,10 @@ class AppLocalizationsEn extends AppLocalizations { String get loginOtherSignInMethods => 'Other sign-in methods'; @override - String get loginTermsIntro => 'By continuing, you agree to \n'; + String get loginTermsLink => 'Terms of Use «Komet»'; @override - String get loginTermsLink => 'the terms of use'; - - @override - String get loginTermsOfUse => 'Terms of use'; + String get loginTermsOfUse => 'Terms of Use «Komet»'; @override String get loginConfirmPhoneTitle => 'Is this the correct number?'; @@ -46,7 +43,8 @@ class AppLocalizationsEn extends AppLocalizations { String get loginDone => 'Done'; @override - String get loginReadTermsNotification => 'Please read the terms of use first'; + String get loginReadTermsNotification => + 'Please read the Terms of Use «Komet»'; @override String get loginSpoofRedacted => 'Spoof redaction'; @@ -55,13 +53,13 @@ class AppLocalizationsEn extends AppLocalizations { String get loginProxy => 'Proxy'; @override - String get loginSignInWithQr => 'Sign in with QR code'; + String get loginSignInWithQr => 'Log in with QR code'; @override - String get loginSignInWithToken => 'Sign in with token'; + String get loginSignInWithToken => 'Log in with token'; @override - String get loginSignInWithSessionFile => 'Sign in with session file'; + String get loginSignInWithSessionFile => 'Log in with session file'; @override String get loginLanguage => 'Language'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e63e96d..63d6d7d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -28,13 +28,10 @@ class AppLocalizationsRu extends AppLocalizations { String get loginOtherSignInMethods => 'Другие способы входа'; @override - String get loginTermsIntro => 'Продолжая, вы соглашаетесь с \n'; + String get loginTermsLink => 'Условия использования «Komet»'; @override - String get loginTermsLink => 'пользовательскими соглашениями'; - - @override - String get loginTermsOfUse => 'Условия использования'; + String get loginTermsOfUse => 'Условия использования «Komet»'; @override String get loginConfirmPhoneTitle => 'Это правильный номер?'; @@ -47,7 +44,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginReadTermsNotification => - 'Сначала прочитайте условия использования'; + 'Сначала прочитайте условия использования «Komet»'; @override String get loginSpoofRedacted => 'Подделка спуфа'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a53ed02..49ded61 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7,12 +7,12 @@ "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Другие способы входа", "loginTermsIntro": "Продолжая, вы соглашаетесь с \n", - "loginTermsLink": "пользовательскими соглашениями", - "loginTermsOfUse": "Условия использования", + "loginTermsLink": "Условия использования «Komet»", + "loginTermsOfUse": "Условия использования «Komet»", "loginConfirmPhoneTitle": "Это правильный номер?", "loginEdit": "Изменить", "loginDone": "Готово", - "loginReadTermsNotification": "Сначала прочитайте условия использования", + "loginReadTermsNotification": "Сначала прочитайте условия использования «Komet»", "loginSpoofRedacted": "Подделка спуфа", "loginProxy": "Прокси", "loginSignInWithQr": "По QR code", diff --git a/pubspec.lock b/pubspec.lock index 62a257f..acfb34b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -530,6 +530,14 @@ packages: description: flutter source: sdk version: "0.0.0" + smart_auth: + dependency: "direct main" + description: + name: smart_auth + sha256: a536423c50d71e9a311d16027346634d0deadd07dafc5b5606b46719cf6fb2b6 + url: "https://pub.dev" + source: hosted + version: "3.2.0" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0341c9a..7af58b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: shared_preferences: ^2.5.4 package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 + smart_auth: ^3.2.0 dev_dependencies: flutter_test: From 8a838b0b7c6c32cc25109e51a8a1ac778dd75b2f Mon Sep 17 00:00:00 2001 From: Sergej Nekrasov <130226127+SergejWinston@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:56:29 +0700 Subject: [PATCH 39/59] Update gradle.properties --- android/gradle.properties | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index 398cde8..1cc1815 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,8 +1,4 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true kotlin.incremental=false -dev.steenbakker.mobile_scanner.useUnbundled=true -systemProp.socksProxyHost=127.0.0.1 -systemProp.socksProxyPort=10808 -systemProp.https.proxyHost=127.0.0.1 -systemProp.https.proxyPort=10808 +dev.steenbakker.mobile_scanner.useUnbundled=true \ No newline at end of file From 3e1a1274dcba3c2802b24559ff8a025df9c8cbcc Mon Sep 17 00:00:00 2001 From: Sergej Nekrasov <130226127+SergejWinston@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:10:18 +0000 Subject: [PATCH 40/59] Revert "Update gradle.properties" This reverts commit e320487950af0e7894d58bdb4b18659fb1ee142e. --- android/gradle.properties | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/android/gradle.properties b/android/gradle.properties index 1cc1815..398cde8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,8 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true kotlin.incremental=false -dev.steenbakker.mobile_scanner.useUnbundled=true \ No newline at end of file +dev.steenbakker.mobile_scanner.useUnbundled=true +systemProp.socksProxyHost=127.0.0.1 +systemProp.socksProxyPort=10808 +systemProp.https.proxyHost=127.0.0.1 +systemProp.https.proxyPort=10808 From 21c1da5c77e661bb27fe13b892e92be135c55cea Mon Sep 17 00:00:00 2001 From: Sergej Nekrasov <130226127+SergejWinston@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:10:31 +0000 Subject: [PATCH 41/59] Revert "feat(auth): integrate SmartAuth for SMS code auto-fill and update localization strings" This reverts commit 37e320a52b0476946df15d2ebc4659d714c90d8c. --- android/gradle.properties | 4 -- .../auth/code_confirmation_screen.dart | 65 ++----------------- lib/frontend/screens/auth/login_screen.dart | 9 +++ lib/l10n/app_en.arb | 15 +++-- lib/l10n/app_localizations.dart | 20 ++++-- lib/l10n/app_localizations_en.dart | 18 ++--- lib/l10n/app_localizations_ru.dart | 9 ++- lib/l10n/app_ru.arb | 6 +- pubspec.lock | 8 --- pubspec.yaml | 1 - 10 files changed, 54 insertions(+), 101 deletions(-) diff --git a/android/gradle.properties b/android/gradle.properties index 398cde8..492b2f1 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,7 +2,3 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true kotlin.incremental=false dev.steenbakker.mobile_scanner.useUnbundled=true -systemProp.socksProxyHost=127.0.0.1 -systemProp.socksProxyPort=10808 -systemProp.https.proxyHost=127.0.0.1 -systemProp.https.proxyPort=10808 diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 21a2194..3840751 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -1,10 +1,8 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:komet/l10n/app_localizations.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:smart_auth/smart_auth.dart'; import '../chats/chat_list_screen.dart'; import 'password_2fa_screen.dart'; import '../../../main.dart'; @@ -28,11 +26,9 @@ class _CodeConfirmationScreenState extends State with TickerProviderStateMixin { final TextEditingController _codeController = TextEditingController(); final FocusNode _focusNode = FocusNode(); - final SmartAuth _smartAuth = SmartAuth.instance; int _timerSeconds = 30; Timer? _timer; Timer? _errorTimer; - bool _isListeningSmsConsent = false; String? _errorMessage; late AnimationController _shakeController; @@ -45,7 +41,6 @@ class _CodeConfirmationScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); - _startSmsCodeListener(); _shakeController = AnimationController( vsync: this, @@ -61,63 +56,10 @@ class _CodeConfirmationScreenState extends State ]).animate(CurvedAnimation(parent: _shakeController, curve: Curves.linear)); } - Future _startSmsCodeListener() async { - if (defaultTargetPlatform != TargetPlatform.android) return; - if (_isListeningSmsConsent) return; - - _isListeningSmsConsent = true; - final result = await _smartAuth.getSmsWithUserConsentApi(matcher: r'\d{6}'); - _isListeningSmsConsent = false; - - if (!mounted || !result.hasData) return; - - final receivedCode = result.data?.code; - if (receivedCode == null || receivedCode.isEmpty) return; - - _applyAutoFillCode(receivedCode); - } - - Future _restartSmsCodeListener() async { - if (defaultTargetPlatform != TargetPlatform.android) return; - await _smartAuth.removeUserConsentApiListener(); - _isListeningSmsConsent = false; - await _startSmsCodeListener(); - } - - void _handleCodeChanged(String value) { - setState(() { - if (_errorMessage != null) { - _errorMessage = null; - } - }); - if (value.length == 6) { - _verifyCode(); - } - } - - void _applyAutoFillCode(String rawCode) { - final digitsOnly = rawCode.replaceAll(RegExp(r'\D'), ''); - if (digitsOnly.isEmpty) return; - - final nextCode = digitsOnly.length <= 6 - ? digitsOnly - : digitsOnly.substring(0, 6); - - if (_codeController.text == nextCode) return; - - _codeController.value = TextEditingValue( - text: nextCode, - selection: TextSelection.collapsed(offset: nextCode.length), - ); - - _handleCodeChanged(nextCode); - } - @override void dispose() { _timer?.cancel(); _errorTimer?.cancel(); - unawaited(_smartAuth.removeUserConsentApiListener()); _shakeController.dispose(); _codeController.dispose(); _focusNode.dispose(); @@ -150,7 +92,6 @@ class _CodeConfirmationScreenState extends State void _resendCode() { if (_timerSeconds == 0) { _startTimer(); - unawaited(_restartSmsCodeListener()); } } @@ -265,7 +206,11 @@ class _CodeConfirmationScreenState extends State FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6), ], - onChanged: _handleCodeChanged, + onChanged: (value) { + if (hasError) setState(() => _errorMessage = null); + setState(() {}); + if (value.length == 6) _verifyCode(); + }, ), ), ), diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 08f74e8..579f250 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -795,6 +795,15 @@ class _LoginScreenState extends State { height: 1.4, ), children: [ + TextSpan( + text: l10n.loginTermsIntro, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), TextSpan( text: l10n.loginTermsLink, style: GoogleFonts.inter( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7ec3f63..aa424d8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,22 +1,23 @@ { "@@locale": "en", - "loginTitle": "Log in to Komet", + "loginTitle": "Sign in to Komet", "loginSubtitle": "Check your country code and enter your\nphone number.", "loginCountry": "Country", "loginPhoneNumber": "Phone number", "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Other sign-in methods", - "loginTermsLink": "Terms of Use «Komet»", - "loginTermsOfUse": "Terms of Use «Komet»", + "loginTermsIntro": "By continuing, you agree to \n", + "loginTermsLink": "the terms of use", + "loginTermsOfUse": "Terms of use", "loginConfirmPhoneTitle": "Is this the correct number?", "loginEdit": "Change", "loginDone": "Done", - "loginReadTermsNotification": "Please read the Terms of Use «Komet»", + "loginReadTermsNotification": "Please read the terms of use first", "loginSpoofRedacted": "Spoof redaction", "loginProxy": "Proxy", - "loginSignInWithQr": "Log in with QR code", - "loginSignInWithToken": "Log in with token", - "loginSignInWithSessionFile": "Log in with session file", + "loginSignInWithQr": "Sign in with QR code", + "loginSignInWithToken": "Sign in with token", + "loginSignInWithSessionFile": "Sign in with session file", "loginLanguage": "Language", "languageNameRu": "Русский", "languageNameEn": "English", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 67816f8..b0614d5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -101,7 +101,7 @@ abstract class AppLocalizations { /// No description provided for @loginTitle. /// /// In en, this message translates to: - /// **'Log in to Komet'** + /// **'Sign in to Komet'** String get loginTitle; /// No description provided for @loginSubtitle. @@ -134,16 +134,22 @@ abstract class AppLocalizations { /// **'Other sign-in methods'** String get loginOtherSignInMethods; + /// No description provided for @loginTermsIntro. + /// + /// In en, this message translates to: + /// **'By continuing, you agree to \n'** + String get loginTermsIntro; + /// No description provided for @loginTermsLink. /// /// In en, this message translates to: - /// **'Terms of Use «Komet»'** + /// **'the terms of use'** String get loginTermsLink; /// No description provided for @loginTermsOfUse. /// /// In en, this message translates to: - /// **'Terms of Use «Komet»'** + /// **'Terms of use'** String get loginTermsOfUse; /// No description provided for @loginConfirmPhoneTitle. @@ -167,7 +173,7 @@ abstract class AppLocalizations { /// No description provided for @loginReadTermsNotification. /// /// In en, this message translates to: - /// **'Please read the Terms of Use «Komet»'** + /// **'Please read the terms of use first'** String get loginReadTermsNotification; /// No description provided for @loginSpoofRedacted. @@ -185,19 +191,19 @@ abstract class AppLocalizations { /// No description provided for @loginSignInWithQr. /// /// In en, this message translates to: - /// **'Log in with QR code'** + /// **'Sign in with QR code'** String get loginSignInWithQr; /// No description provided for @loginSignInWithToken. /// /// In en, this message translates to: - /// **'Log in with token'** + /// **'Sign in with token'** String get loginSignInWithToken; /// No description provided for @loginSignInWithSessionFile. /// /// In en, this message translates to: - /// **'Log in with session file'** + /// **'Sign in with session file'** String get loginSignInWithSessionFile; /// No description provided for @loginLanguage. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3f6e67e..5ddbe9b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override - String get loginTitle => 'Log in to Komet'; + String get loginTitle => 'Sign in to Komet'; @override String get loginSubtitle => @@ -28,10 +28,13 @@ class AppLocalizationsEn extends AppLocalizations { String get loginOtherSignInMethods => 'Other sign-in methods'; @override - String get loginTermsLink => 'Terms of Use «Komet»'; + String get loginTermsIntro => 'By continuing, you agree to \n'; @override - String get loginTermsOfUse => 'Terms of Use «Komet»'; + String get loginTermsLink => 'the terms of use'; + + @override + String get loginTermsOfUse => 'Terms of use'; @override String get loginConfirmPhoneTitle => 'Is this the correct number?'; @@ -43,8 +46,7 @@ class AppLocalizationsEn extends AppLocalizations { String get loginDone => 'Done'; @override - String get loginReadTermsNotification => - 'Please read the Terms of Use «Komet»'; + String get loginReadTermsNotification => 'Please read the terms of use first'; @override String get loginSpoofRedacted => 'Spoof redaction'; @@ -53,13 +55,13 @@ class AppLocalizationsEn extends AppLocalizations { String get loginProxy => 'Proxy'; @override - String get loginSignInWithQr => 'Log in with QR code'; + String get loginSignInWithQr => 'Sign in with QR code'; @override - String get loginSignInWithToken => 'Log in with token'; + String get loginSignInWithToken => 'Sign in with token'; @override - String get loginSignInWithSessionFile => 'Log in with session file'; + String get loginSignInWithSessionFile => 'Sign in with session file'; @override String get loginLanguage => 'Language'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 63d6d7d..e63e96d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -28,10 +28,13 @@ class AppLocalizationsRu extends AppLocalizations { String get loginOtherSignInMethods => 'Другие способы входа'; @override - String get loginTermsLink => 'Условия использования «Komet»'; + String get loginTermsIntro => 'Продолжая, вы соглашаетесь с \n'; @override - String get loginTermsOfUse => 'Условия использования «Komet»'; + String get loginTermsLink => 'пользовательскими соглашениями'; + + @override + String get loginTermsOfUse => 'Условия использования'; @override String get loginConfirmPhoneTitle => 'Это правильный номер?'; @@ -44,7 +47,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginReadTermsNotification => - 'Сначала прочитайте условия использования «Komet»'; + 'Сначала прочитайте условия использования'; @override String get loginSpoofRedacted => 'Подделка спуфа'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 49ded61..a53ed02 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7,12 +7,12 @@ "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Другие способы входа", "loginTermsIntro": "Продолжая, вы соглашаетесь с \n", - "loginTermsLink": "Условия использования «Komet»", - "loginTermsOfUse": "Условия использования «Komet»", + "loginTermsLink": "пользовательскими соглашениями", + "loginTermsOfUse": "Условия использования", "loginConfirmPhoneTitle": "Это правильный номер?", "loginEdit": "Изменить", "loginDone": "Готово", - "loginReadTermsNotification": "Сначала прочитайте условия использования «Komet»", + "loginReadTermsNotification": "Сначала прочитайте условия использования", "loginSpoofRedacted": "Подделка спуфа", "loginProxy": "Прокси", "loginSignInWithQr": "По QR code", diff --git a/pubspec.lock b/pubspec.lock index acfb34b..62a257f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -530,14 +530,6 @@ packages: description: flutter source: sdk version: "0.0.0" - smart_auth: - dependency: "direct main" - description: - name: smart_auth - sha256: a536423c50d71e9a311d16027346634d0deadd07dafc5b5606b46719cf6fb2b6 - url: "https://pub.dev" - source: hosted - version: "3.2.0" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7af58b7..0341c9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,7 +53,6 @@ dependencies: shared_preferences: ^2.5.4 package_info_plus: ^9.0.1 mobile_scanner: ^7.2.0 - smart_auth: ^3.2.0 dev_dependencies: flutter_test: From f166d0e92b41e15a901d7b705467dcf3973bd8bd Mon Sep 17 00:00:00 2001 From: Sergej Nekrasov <130226127+SergejWinston@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:11:35 +0700 Subject: [PATCH 42/59] Update .gitignore revent... --- .gitignore | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 0d6d7ea..14601b5 100644 --- a/.gitignore +++ b/.gitignore @@ -125,7 +125,4 @@ app.*.symbols !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -!/dev/ci/**/Gemfile.lock - - -AGENTS.md \ No newline at end of file +!/dev/ci/**/Gemfile.lock \ No newline at end of file From dc2a23ccf05570b168415fe9b0413b68f8d5dd87 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 7 Apr 2026 11:16:30 +0300 Subject: [PATCH 43/59] chore(dependencies): update device_info_plus to version 12.3.0 and adjust related project files --- ios/Podfile.lock | 62 ++++++++++ ios/Runner.xcodeproj/project.pbxproj | 112 ++++++++++++++++++ .../contents.xcworkspacedata | 3 + pubspec.lock | 4 +- pubspec.yaml | 2 +- 5 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 ios/Podfile.lock diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..308466c --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,62 @@ +PODS: + - device_info_plus (0.0.1): + - Flutter + - Flutter (1.0.0) + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + - flutter_timezone (0.0.1): + - Flutter + - mobile_scanner (7.0.0): + - Flutter + - FlutterMacOS + - package_info_plus (0.4.5): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - Flutter (from `Flutter`) + - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + +EXTERNAL SOURCES: + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + Flutter: + :path: Flutter + flutter_secure_storage_darwin: + :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + flutter_timezone: + :path: ".symlinks/plugins/flutter_timezone/ios" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/darwin" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + +SPEC CHECKSUMS: + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d + flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282 + mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 765a142..5b0a064 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,10 +10,12 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + E835CC948F2F948DC3BBF405 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,12 +44,19 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 28C4A8DBB53D5AC72DDB74A4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 366C243BBAFE3F87FBDF57A1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 83CE6DC2BACBB6F59AEA57B6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,13 +64,23 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 2594AD7A0161CA1A66191F05 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E835CC948F2F948DC3BBF405 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,6 +95,15 @@ path = RunnerTests; sourceTree = ""; }; + 3E066142FD515067AADAC58D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */, + 2AE8CB2654009FE276CACA4B /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +122,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + C4C44F130EEB07A77FD05026 /* Pods */, + 3E066142FD515067AADAC58D /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +151,20 @@ path = Runner; sourceTree = ""; }; + C4C44F130EEB07A77FD05026 /* Pods */ = { + isa = PBXGroup; + children = ( + 83CE6DC2BACBB6F59AEA57B6 /* Pods-Runner.debug.xcconfig */, + 366C243BBAFE3F87FBDF57A1 /* Pods-Runner.release.xcconfig */, + 28C4A8DBB53D5AC72DDB74A4 /* Pods-Runner.profile.xcconfig */, + 1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */, + 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */, + 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 942752100DC29827FE94D2B2 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 2594AD7A0161CA1A66191F05 /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 8FBC28ABA859CAFFB7CFEFA3 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 122816C3E1F98387D8AA58D3 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -222,6 +270,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 122816C3E1F98387D8AA58D3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -238,6 +303,50 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 8FBC28ABA859CAFFB7CFEFA3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 942752100DC29827FE94D2B2 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -378,6 +487,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 1615A82960D98F780416FDE7 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +505,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +521,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9D7B0FFE63E0BD5D67D7758D /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/pubspec.lock b/pubspec.lock index 62a257f..1f7d572 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" url: "https://pub.dev" source: hosted - version: "12.4.0" + version: "12.3.0" device_info_plus_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0341c9a..f681c96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: dart_lz4: ^1.0.0 msgpack_dart: ^1.0.1 logger: ^2.6.2 - device_info_plus: ^12.3.0 + device_info_plus: 12.3.0 flutter_timezone: ^5.0.1 timezone: ^0.11.0 flutter_secure_storage: ^10.0.0 From 91bdfea222e3133b4d32ff0e8c803953765236a1 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 7 Apr 2026 13:36:30 +0300 Subject: [PATCH 44/59] feat(auth): server endpoint in bottom sheet on login --- lib/backend/api.dart | 3 +- lib/core/config/config.dart | 22 +- lib/frontend/screens/auth/login_screen.dart | 39 +++- .../screens/auth/server_settings_sheet.dart | 215 ++++++++++++++++++ lib/l10n/app_en.arb | 9 + lib/l10n/app_localizations.dart | 54 +++++ lib/l10n/app_localizations_en.dart | 27 +++ lib/l10n/app_localizations_ru.dart | 28 +++ lib/l10n/app_ru.arb | 9 + pubspec.lock | 16 +- 10 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 lib/frontend/screens/auth/server_settings_sheet.dart diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 402cf92..7751155 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -70,7 +70,8 @@ class Api { }); try { - await _connection.connect(ServerConfig.host, ServerConfig.port); + final endpoint = await ServerConfig.loadEndpoint(); + await _connection.connect(endpoint.host, endpoint.port); } catch (e) { logger.e('Не удалось подключиться: $e'); _cleanup(); diff --git a/lib/core/config/config.dart b/lib/core/config/config.dart index 4ef18b3..c0c97cc 100644 --- a/lib/core/config/config.dart +++ b/lib/core/config/config.dart @@ -1,7 +1,25 @@ +import 'package:shared_preferences/shared_preferences.dart'; + abstract class ServerConfig { - static const String host = 'api.oneme.ru'; - static const int port = 443; + static const String defaultHost = 'api.oneme.ru'; + static const int defaultPort = 443; + static const String prefHostKey = 'server_host_override'; + static const String prefPortKey = 'server_port_override'; static const Duration pingInterval = Duration(seconds: 30); static const Duration requestTimeout = Duration(seconds: 30); static const int maxReconnectAttempts = 50; + + static Future<({String host, int port})> loadEndpoint() async { + final prefs = await SharedPreferences.getInstance(); + final rawHost = prefs.getString(prefHostKey); + final rawPort = prefs.getInt(prefPortKey); + final host = (rawHost != null && rawHost.trim().isNotEmpty) + ? rawHost.trim() + : defaultHost; + var port = defaultPort; + if (rawPort != null && rawPort >= 1 && rawPort <= 65535) { + port = rawPort; + } + return (host: host, port: port); + } } diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 579f250..a531b1d 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -10,6 +10,7 @@ import 'package:komet/l10n/terms_of_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; +import 'server_settings_sheet.dart'; import 'spoff_redacted_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../../main.dart'; @@ -448,6 +449,23 @@ class _LoginScreenState extends State { _showPhoneConfirmationDialog(_phoneController.text); } + void _showServerSettingsSheet(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) { + return SafeArea( + child: const ServerSettingsSheet(), + ); + }, + ); + } + void _showSecurityOptions(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; @@ -457,7 +475,7 @@ class _LoginScreenState extends State { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), - builder: (context) { + builder: (sheetContext) { return SafeArea( child: Padding( padding: const EdgeInsets.symmetric( @@ -478,7 +496,7 @@ class _LoginScreenState extends State { ), ), onTap: () { - Navigator.pop(context); + Navigator.pop(sheetContext); Navigator.push( context, MaterialPageRoute( @@ -498,7 +516,22 @@ class _LoginScreenState extends State { ), ), onTap: () { - Navigator.pop(context); + Navigator.pop(sheetContext); + }, + ), + ListTile( + leading: Icon(Symbols.dns, color: cs.onSurface), + title: Text( + l10n.loginChangeServer, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + _showServerSettingsSheet(context); }, ), ], diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart new file mode 100644 index 0000000..9c85651 --- /dev/null +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/backend/api.dart'; +import 'package:komet/core/config/config.dart'; +import 'package:komet/l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; + +class ServerSettingsSheet extends StatefulWidget { + const ServerSettingsSheet({super.key}); + + @override + State createState() => _ServerSettingsSheetState(); +} + +class _ServerSettingsSheetState extends State { + final TextEditingController _hostController = TextEditingController( + text: ServerConfig.defaultHost, + ); + final TextEditingController _portController = TextEditingController( + text: '${ServerConfig.defaultPort}', + ); + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final endpoint = await ServerConfig.loadEndpoint(); + if (!mounted) return; + setState(() { + _hostController.text = endpoint.host; + _portController.text = '${endpoint.port}'; + }); + } + + Future _apply(AppLocalizations l10n) async { + final host = _hostController.text.trim(); + final port = int.tryParse(_portController.text.trim()); + if (host.isEmpty || port == null || port < 1 || port > 65535) { + showCustomNotification(context, l10n.serverInvalidHostOrPort); + return; + } + setState(() => _busy = true); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(ServerConfig.prefHostKey, host); + await prefs.setInt(ServerConfig.prefPortKey, port); + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.serverSettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _resetToDefault(AppLocalizations l10n) async { + setState(() => _busy = true); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(ServerConfig.prefHostKey); + await prefs.remove(ServerConfig.prefPortKey); + _hostController.text = ServerConfig.defaultHost; + _portController.text = '${ServerConfig.defaultPort}'; + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.serverSettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + l10n.serverSettingsTitle, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + _buildTextField( + controller: _hostController, + label: l10n.serverHostLabel, + hintText: ServerConfig.defaultHost, + cs: cs, + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _portController, + label: l10n.serverPortLabel, + hintText: '${ServerConfig.defaultPort}', + cs: cs, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _busy ? null : () => _apply(l10n), + child: Text(l10n.serverApply), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: _busy ? null : () => _resetToDefault(l10n), + child: Text(l10n.serverUseDefault), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required ColorScheme cs, + String? hintText, + TextInputType? keyboardType, + List? inputFormatters, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + enabled: !_busy, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: GoogleFonts.inter( + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 15, + ), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index aa424d8..57a5c1c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15,6 +15,15 @@ "loginReadTermsNotification": "Please read the terms of use first", "loginSpoofRedacted": "Spoof redaction", "loginProxy": "Proxy", + "loginChangeServer": "Change server", + "serverSettingsTitle": "Server", + "serverHostLabel": "Host", + "serverPortLabel": "Port", + "serverApply": "Apply and reconnect", + "serverUseDefault": "Reset to default", + "serverInvalidHostOrPort": "Enter a valid host and port (1–65535)", + "serverSettingsSaved": "Server settings applied", + "serverReconnectFailed": "Could not connect to the server", "loginSignInWithQr": "Sign in with QR code", "loginSignInWithToken": "Sign in with token", "loginSignInWithSessionFile": "Sign in with session file", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b0614d5..605d726 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -188,6 +188,60 @@ abstract class AppLocalizations { /// **'Proxy'** String get loginProxy; + /// No description provided for @loginChangeServer. + /// + /// In en, this message translates to: + /// **'Change server'** + String get loginChangeServer; + + /// No description provided for @serverSettingsTitle. + /// + /// In en, this message translates to: + /// **'Server'** + String get serverSettingsTitle; + + /// No description provided for @serverHostLabel. + /// + /// In en, this message translates to: + /// **'Host'** + String get serverHostLabel; + + /// No description provided for @serverPortLabel. + /// + /// In en, this message translates to: + /// **'Port'** + String get serverPortLabel; + + /// No description provided for @serverApply. + /// + /// In en, this message translates to: + /// **'Apply and reconnect'** + String get serverApply; + + /// No description provided for @serverUseDefault. + /// + /// In en, this message translates to: + /// **'Reset to default'** + String get serverUseDefault; + + /// No description provided for @serverInvalidHostOrPort. + /// + /// In en, this message translates to: + /// **'Enter a valid host and port (1–65535)'** + String get serverInvalidHostOrPort; + + /// No description provided for @serverSettingsSaved. + /// + /// In en, this message translates to: + /// **'Server settings applied'** + String get serverSettingsSaved; + + /// No description provided for @serverReconnectFailed. + /// + /// In en, this message translates to: + /// **'Could not connect to the server'** + String get serverReconnectFailed; + /// No description provided for @loginSignInWithQr. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5ddbe9b..32b6401 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -54,6 +54,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loginProxy => 'Proxy'; + @override + String get loginChangeServer => 'Change server'; + + @override + String get serverSettingsTitle => 'Server'; + + @override + String get serverHostLabel => 'Host'; + + @override + String get serverPortLabel => 'Port'; + + @override + String get serverApply => 'Apply and reconnect'; + + @override + String get serverUseDefault => 'Reset to default'; + + @override + String get serverInvalidHostOrPort => 'Enter a valid host and port (1–65535)'; + + @override + String get serverSettingsSaved => 'Server settings applied'; + + @override + String get serverReconnectFailed => 'Could not connect to the server'; + @override String get loginSignInWithQr => 'Sign in with QR code'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e63e96d..f1f7049 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -55,6 +55,34 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginProxy => 'Прокси'; + @override + String get loginChangeServer => 'Смена сервера'; + + @override + String get serverSettingsTitle => 'Сервер'; + + @override + String get serverHostLabel => 'Хост'; + + @override + String get serverPortLabel => 'Порт'; + + @override + String get serverApply => 'Применить и переподключиться'; + + @override + String get serverUseDefault => 'Сбросить к умолчанию'; + + @override + String get serverInvalidHostOrPort => + 'Укажите корректный хост и порт (1–65535)'; + + @override + String get serverSettingsSaved => 'Настройки сервера применены'; + + @override + String get serverReconnectFailed => 'Не удалось подключиться к серверу'; + @override String get loginSignInWithQr => 'По QR code'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a53ed02..fdf86aa 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -15,6 +15,15 @@ "loginReadTermsNotification": "Сначала прочитайте условия использования", "loginSpoofRedacted": "Подделка спуфа", "loginProxy": "Прокси", + "loginChangeServer": "Смена сервера", + "serverSettingsTitle": "Сервер", + "serverHostLabel": "Хост", + "serverPortLabel": "Порт", + "serverApply": "Применить и переподключиться", + "serverUseDefault": "Сбросить к умолчанию", + "serverInvalidHostOrPort": "Укажите корректный хост и порт (1–65535)", + "serverSettingsSaved": "Настройки сервера применены", + "serverReconnectFailed": "Не удалось подключиться к серверу", "loginSignInWithQr": "По QR code", "loginSignInWithToken": "По токену", "loginSignInWithSessionFile": "По файлу сессии", diff --git a/pubspec.lock b/pubspec.lock index 1f7d572..b09c9ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" timezone: dependency: "direct main" description: From 7f5a8252c3a6224584dd7d91d756b16f2106d2a4 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 7 Apr 2026 13:50:34 +0300 Subject: [PATCH 45/59] fix(auth): per-country phone mask hint and Indonesia digit count --- lib/core/config/countries.dart | 4 ++-- lib/frontend/screens/auth/login_screen.dart | 7 ++++++- lib/l10n/app_en.arb | 1 - lib/l10n/app_localizations.dart | 6 ------ lib/l10n/app_localizations_en.dart | 3 --- lib/l10n/app_localizations_ru.dart | 3 --- lib/l10n/app_ru.arb | 1 - 7 files changed, 8 insertions(+), 17 deletions(-) diff --git a/lib/core/config/countries.dart b/lib/core/config/countries.dart index e87262e..e4120c3 100644 --- a/lib/core/config/countries.dart +++ b/lib/core/config/countries.dart @@ -166,7 +166,7 @@ const String _countriesEnJson = '''[ {"id": 348, "alpha2": "hu", "alpha3": "hun", "name": "Hungary", "phoneCode": "+36", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 352, "alpha2": "is", "alpha3": "isl", "name": "Iceland", "phoneCode": "+354", "phoneDigits": 7, "phoneMask": "### ####", "phoneGroupSizes": [3, 4], "phoneGroupSeparators": ["", " ", ""]}, {"id": 356, "alpha2": "in", "alpha3": "ind", "name": "India", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]}, -{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Indonesia", "phoneCode": "+62", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Iran, Islamic Republic of", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Iraq", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 372, "alpha2": "ie", "alpha3": "irl", "name": "Ireland", "phoneCode": "+353", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, @@ -342,7 +342,7 @@ const String _countriesRuJson = '''[ {"id": 716, "alpha2": "zw", "alpha3": "zwe", "name": "Зимбабве", "phoneCode": "+263", "phoneDigits": 9, "phoneMask": "## ### ####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 376, "alpha2": "il", "alpha3": "isr", "name": "Израиль", "phoneCode": "+972", "phoneDigits": 9, "phoneMask": "##-###-####", "phoneGroupSizes": [2, 3, 4], "phoneGroupSeparators": ["", "-", "-", ""]}, {"id": 356, "alpha2": "in", "alpha3": "ind", "name": "Индия", "phoneCode": "+91", "phoneDigits": 10, "phoneMask": "##### #####", "phoneGroupSizes": [5, 5], "phoneGroupSeparators": ["", " ", ""]}, -{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 10, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, +{"id": 360, "alpha2": "id", "alpha3": "idn", "name": "Индонезия", "phoneCode": "+62", "phoneDigits": 11, "phoneMask": "### #### ####", "phoneGroupSizes": [3, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 400, "alpha2": "jo", "alpha3": "jor", "name": "Иордания", "phoneCode": "+962", "phoneDigits": 9, "phoneMask": "# #### ####", "phoneGroupSizes": [1, 4, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 368, "alpha2": "iq", "alpha3": "irq", "name": "Ирак", "phoneCode": "+964", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, {"id": 364, "alpha2": "ir", "alpha3": "irn", "name": "Иран", "phoneCode": "+98", "phoneDigits": 10, "phoneMask": "### ### ####", "phoneGroupSizes": [3, 3, 4], "phoneGroupSeparators": ["", " ", " ", ""]}, diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index a531b1d..e182159 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -97,6 +97,10 @@ class _LoginScreenState extends State { return lang == 'ru' ? country.ru : country.en; } + String _phoneMaskHint(CountryName country) { + return country.phoneMask.replaceAll('#', '0'); + } + void _showLanguagePicker() { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; @@ -740,6 +744,7 @@ class _LoginScreenState extends State { const SizedBox(width: 12), Expanded( child: TextField( + key: ValueKey(_selectedCountry.code), controller: _phoneController, keyboardType: TextInputType.phone, inputFormatters: [ @@ -752,7 +757,7 @@ class _LoginScreenState extends State { fontWeight: FontWeight.w400, ), decoration: InputDecoration( - hintText: l10n.loginPhoneHint, + hintText: _phoneMaskHint(_selectedCountry), hintStyle: TextStyle( color: cs.outline, fontSize: 15, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 57a5c1c..3d832a0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4,7 +4,6 @@ "loginSubtitle": "Check your country code and enter your\nphone number.", "loginCountry": "Country", "loginPhoneNumber": "Phone number", - "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Other sign-in methods", "loginTermsIntro": "By continuing, you agree to \n", "loginTermsLink": "the terms of use", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 605d726..05cc593 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -122,12 +122,6 @@ abstract class AppLocalizations { /// **'Phone number'** String get loginPhoneNumber; - /// No description provided for @loginPhoneHint. - /// - /// In en, this message translates to: - /// **'(000) 000-00-00'** - String get loginPhoneHint; - /// No description provided for @loginOtherSignInMethods. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 32b6401..df7fda7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -21,9 +21,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loginPhoneNumber => 'Phone number'; - @override - String get loginPhoneHint => '(000) 000-00-00'; - @override String get loginOtherSignInMethods => 'Other sign-in methods'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index f1f7049..6a92039 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -21,9 +21,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginPhoneNumber => 'Номер телефона'; - @override - String get loginPhoneHint => '(000) 000-00-00'; - @override String get loginOtherSignInMethods => 'Другие способы входа'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fdf86aa..1d92905 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -4,7 +4,6 @@ "loginSubtitle": "Проверьте код страны и введите свой\nномер телефона.", "loginCountry": "Страна", "loginPhoneNumber": "Номер телефона", - "loginPhoneHint": "(000) 000-00-00", "loginOtherSignInMethods": "Другие способы входа", "loginTermsIntro": "Продолжая, вы соглашаетесь с \n", "loginTermsLink": "пользовательскими соглашениями", From 1a37f29fd1fa1eab1de3109da0e500198834db0d Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 7 Apr 2026 19:27:05 +0300 Subject: [PATCH 46/59] fix(auth): strip formatting from phone before requestCode --- lib/backend/modules/account.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 52fe39d..d86108f 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -10,6 +10,11 @@ import 'chats.dart'; import 'contacts.dart'; import 'folders.dart'; +String _normalizeAuthPhone(String phone) { + final digits = phone.replaceAll(RegExp(r'\D'), ''); + return '+$digits'; +} + class PrivacyConfig { final String searchByPhone; final String incomingCall; @@ -976,13 +981,15 @@ class AccountModule { ) async { _ensureOnline(); + final normalizedPhone = _normalizeAuthPhone(phone); + final payload = { - 'phone': phone, + 'phone': normalizedPhone, 'type': type.value, 'language': language, }; - logger.i('Запрос OTP-кода: phone=$phone type=${type.value}'); + logger.i('Запрос OTP-кода: phone=$normalizedPhone type=${type.value}'); final packet = await _api.sendRequest(Opcode.authRequest, payload); From a3555ea57903167b1d8934252eb5a0b351e981b9 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 9 Apr 2026 14:48:31 +0300 Subject: [PATCH 47/59] feat(network): SOCKS5 and HTTP(S) proxy support --- lib/core/config/proxy_config.dart | 77 ++++ lib/core/transport/connection.dart | 43 ++- lib/core/transport/proxy_connector.dart | 365 ++++++++++++++++++ lib/frontend/screens/auth/login_screen.dart | 19 + .../screens/auth/proxy_settings_sheet.dart | 306 +++++++++++++++ .../screens/profile/settings_tab.dart | 23 ++ lib/l10n/app_en.arb | 15 +- lib/l10n/app_localizations.dart | 72 ++++ lib/l10n/app_localizations_en.dart | 37 ++ lib/l10n/app_localizations_ru.dart | 37 ++ lib/l10n/app_ru.arb | 15 +- 11 files changed, 999 insertions(+), 10 deletions(-) create mode 100644 lib/core/config/proxy_config.dart create mode 100644 lib/core/transport/proxy_connector.dart create mode 100644 lib/frontend/screens/auth/proxy_settings_sheet.dart diff --git a/lib/core/config/proxy_config.dart b/lib/core/config/proxy_config.dart new file mode 100644 index 0000000..3428fd8 --- /dev/null +++ b/lib/core/config/proxy_config.dart @@ -0,0 +1,77 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +enum ProxyType { none, socks5, httpConnect } + +class ProxySettings { + final ProxyType type; + final String host; + final int port; + final String? username; + final String? password; + + const ProxySettings({ + this.type = ProxyType.none, + this.host = '', + this.port = 1080, + this.username, + this.password, + }); + + bool get isEnabled => type != ProxyType.none && host.isNotEmpty; + + bool get hasCredentials => + username != null && + username!.isNotEmpty && + password != null && + password!.isNotEmpty; +} + +abstract class ProxyConfig { + static const String _prefType = 'proxy_type'; + static const String _prefHost = 'proxy_host'; + static const String _prefPort = 'proxy_port'; + static const String _prefUsername = 'proxy_username'; + static const String _prefPassword = 'proxy_password'; + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final typeIndex = prefs.getInt(_prefType) ?? 0; + final host = prefs.getString(_prefHost) ?? ''; + final port = prefs.getInt(_prefPort) ?? 1080; + final username = prefs.getString(_prefUsername); + final password = prefs.getString(_prefPassword); + return ProxySettings( + type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], + host: host, + port: port, + username: username, + password: password, + ); + } + + static Future save(ProxySettings settings) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_prefType, settings.type.index); + await prefs.setString(_prefHost, settings.host); + await prefs.setInt(_prefPort, settings.port); + if (settings.username != null) { + await prefs.setString(_prefUsername, settings.username!); + } else { + await prefs.remove(_prefUsername); + } + if (settings.password != null) { + await prefs.setString(_prefPassword, settings.password!); + } else { + await prefs.remove(_prefPassword); + } + } + + static Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefType); + await prefs.remove(_prefHost); + await prefs.remove(_prefPort); + await prefs.remove(_prefUsername); + await prefs.remove(_prefPassword); + } +} diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index 16c4444..15c3a53 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -2,14 +2,17 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; +import '../config/proxy_config.dart'; import '../utils/logger.dart'; +import 'proxy_connector.dart'; enum SocketState { disconnected, connecting, connected } /// Обёртка над TCP + TLS сокетом. /// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. class Connection { - SecureSocket? _socket; + RawSecureSocket? _socket; + StreamSubscription? _subscription; SocketState _state = SocketState.disconnected; final _dataController = StreamController.broadcast(); @@ -31,17 +34,39 @@ class Connection { _setState(SocketState.connecting); try { - final socket = await Socket.connect(host, port); - _socket = await SecureSocket.secure( - socket, + final proxySettings = await ProxyConfig.load(); + RawSocket rawSocket; + + if (proxySettings.isEnabled) { + final connector = ProxyConnector(proxySettings); + rawSocket = await connector.connect(host, port); + logger.i('Подключено через прокси ${proxySettings.type.name}'); + } else { + rawSocket = await RawSocket.connect(host, port); + } + + _socket = await RawSecureSocket.secure( + rawSocket, + host: host, onBadCertificate: (_) => true, ); _setState(SocketState.connected); logger.i('Подключено к $host:$port'); - _socket!.listen( - (data) => _dataController.add(Uint8List.fromList(data)), + _subscription = _socket!.listen( + (event) { + if (event == RawSocketEvent.read) { + final data = _socket?.read(); + if (data != null) { + _dataController.add(data); + } + } else if (event == RawSocketEvent.readClosed || + event == RawSocketEvent.closed) { + logger.w('Сокет закрыт сервером'); + disconnect(); + } + }, onError: (Object error) { logger.e('Ошибка сокета: $error'); disconnect(); @@ -62,16 +87,18 @@ class Connection { if (_socket == null || !isConnected) { throw StateError('Нельзя писать: сокет не подключён'); } - _socket!.add(data); + _socket!.write(data); } Future disconnect() async { + _subscription?.cancel(); + _subscription = null; final socket = _socket; _socket = null; if (socket != null) { try { - socket.destroy(); + socket.close(); } catch (_) {} } diff --git a/lib/core/transport/proxy_connector.dart b/lib/core/transport/proxy_connector.dart new file mode 100644 index 0000000..aaac29b --- /dev/null +++ b/lib/core/transport/proxy_connector.dart @@ -0,0 +1,365 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../config/proxy_config.dart'; +import '../utils/logger.dart'; + +/// Устанавливает TCP-соединение через SOCKS5 или HTTP CONNECT прокси. +/// Возвращает [RawSocket], который никогда не слушался — +/// его можно передать в [RawSecureSocket.secure]. +class ProxyConnector { + final ProxySettings settings; + + ProxyConnector(this.settings); + + Future connect(String targetHost, int targetPort) async { + switch (settings.type) { + case ProxyType.socks5: + return _connectSocks5(targetHost, targetPort); + case ProxyType.httpConnect: + return _connectHttpConnect(targetHost, targetPort); + case ProxyType.none: + return RawSocket.connect(targetHost, targetPort); + } + } + + // ── SOCKS5 (RFC 1928) ────────────────────────────────────────────────── + + Future _connectSocks5(String targetHost, int targetPort) async { + final proxySocket = await RawSocket.connect(settings.host, settings.port); + logger.i('SOCKS5: подключено к прокси ${settings.host}:${settings.port}'); + + final io = _RawSocketIO(proxySocket); + try { + // 1. Greeting + final useAuth = settings.hasCredentials; + if (useAuth) { + await io.write([0x05, 0x02, 0x00, 0x02]); + } else { + await io.write([0x05, 0x01, 0x00]); + } + + var response = await io.readExact(2); + if (response[0] != 0x05) { + throw SocketException( + 'SOCKS5: неверная версия протокола: ${response[0]}', + ); + } + + final method = response[1]; + if (method == 0xFF) { + throw SocketException( + 'SOCKS5: сервер отклонил все методы аутентификации', + ); + } + + // 2. Аутентификация (RFC 1929) + if (method == 0x02) { + if (!useAuth) { + throw SocketException('SOCKS5: прокси требует аутентификацию'); + } + final usernameBytes = utf8.encode(settings.username!); + final passwordBytes = utf8.encode(settings.password!); + final authPacket = BytesBuilder() + ..addByte(0x01) + ..addByte(usernameBytes.length) + ..add(usernameBytes) + ..addByte(passwordBytes.length) + ..add(passwordBytes); + await io.write(authPacket.toBytes()); + + final authResponse = await io.readExact(2); + if (authResponse[1] != 0x00) { + throw SocketException('SOCKS5: аутентификация не пройдена'); + } + logger.i('SOCKS5: аутентификация пройдена'); + } + + // 3. Connect request + final hostBytes = utf8.encode(targetHost); + final connectPacket = BytesBuilder() + ..addByte(0x05) // VER + ..addByte(0x01) // CMD: CONNECT + ..addByte(0x00) // RSV + ..addByte(0x03) // ATYP: domain + ..addByte(hostBytes.length) + ..add(hostBytes) + ..addByte((targetPort >> 8) & 0xFF) + ..addByte(targetPort & 0xFF); + await io.write(connectPacket.toBytes()); + + // 4. Reply + final reply = await io.readExact(4); + if (reply[0] != 0x05) { + throw SocketException('SOCKS5: неверная версия в ответе'); + } + if (reply[1] != 0x00) { + throw SocketException( + 'SOCKS5: ошибка подключения, код: ${reply[1]}', + ); + } + + // Пропускаем bind address + switch (reply[3]) { + case 0x01: + await io.readExact(4 + 2); + break; + case 0x03: + final lenBuf = await io.readExact(1); + await io.readExact(lenBuf[0] + 2); + break; + case 0x04: + await io.readExact(16 + 2); + break; + } + + logger.i('SOCKS5: туннель к $targetHost:$targetPort установлен'); + + // Создаём локальную пару и проксируем данные + return _bridgeToFreshSocket(proxySocket, io); + } catch (e) { + io.dispose(); + proxySocket.close(); + rethrow; + } + } + + // ── HTTP CONNECT ──────────────────────────────────────────────────────── + + Future _connectHttpConnect( + String targetHost, + int targetPort, + ) async { + final proxySocket = await RawSocket.connect(settings.host, settings.port); + logger.i( + 'HTTP CONNECT: подключено к прокси ${settings.host}:${settings.port}', + ); + + final io = _RawSocketIO(proxySocket); + try { + final request = StringBuffer() + ..write('CONNECT $targetHost:$targetPort HTTP/1.1\r\n') + ..write('Host: $targetHost:$targetPort\r\n'); + + if (settings.hasCredentials) { + final credentials = base64Encode( + utf8.encode('${settings.username}:${settings.password}'), + ); + request.write('Proxy-Authorization: Basic $credentials\r\n'); + } + request.write('\r\n'); + + await io.write(utf8.encode(request.toString())); + + // Читаем HTTP-ответ до \r\n\r\n + final headerBytes = []; + while (true) { + final byte = await io.readExact(1); + headerBytes.add(byte[0]); + if (headerBytes.length >= 4 && + headerBytes[headerBytes.length - 4] == 0x0D && + headerBytes[headerBytes.length - 3] == 0x0A && + headerBytes[headerBytes.length - 2] == 0x0D && + headerBytes[headerBytes.length - 1] == 0x0A) { + break; + } + if (headerBytes.length > 8192) { + throw SocketException( + 'HTTP CONNECT: заголовок ответа слишком большой', + ); + } + } + + final responseStr = utf8.decode(headerBytes, allowMalformed: true); + final statusLine = responseStr.split('\r\n').first; + final parts = statusLine.split(' '); + final statusCode = parts.length >= 2 ? int.tryParse(parts[1]) ?? 0 : 0; + if (statusCode != 200) { + throw SocketException( + 'HTTP CONNECT: прокси вернул статус $statusCode', + ); + } + + logger.i('HTTP CONNECT: туннель к $targetHost:$targetPort установлен'); + return _bridgeToFreshSocket(proxySocket, io); + } catch (e) { + io.dispose(); + proxySocket.close(); + rethrow; + } + } + + // ── Мост: создаём свежий сокет и проксируем через loopback ───────────── + + /// После handshake proxy-сокет уже прослушан (single-subscription). + /// Создаём пару локальных сокетов через loopback и проксируем данные + /// между прокси-сокетом и одним концом. Второй конец возвращаем — + /// он «свежий» и его можно передать в [RawSecureSocket.secure]. + Future _bridgeToFreshSocket( + RawSocket proxySocket, + _RawSocketIO io, + ) async { + final server = await RawServerSocket.bind( + InternetAddress.loopbackIPv4, + 0, + ); + final clientSide = await RawSocket.connect( + InternetAddress.loopbackIPv4, + server.port, + ); + final serverSide = await server.first; + await server.close(); + + // proxy → local (через уже имеющуюся подписку _RawSocketIO) + io.onData = (data) { + serverSide.write(data); + }; + io.onClosed = () { + serverSide.shutdown(SocketDirection.send); + }; + + // local → proxy + serverSide.listen((event) { + if (event == RawSocketEvent.read) { + final data = serverSide.read(); + if (data != null) proxySocket.write(data); + } else if (event == RawSocketEvent.readClosed || + event == RawSocketEvent.closed) { + proxySocket.shutdown(SocketDirection.send); + } + }); + + // Сливаем данные, буферизованные во время handshake + io.flushBuffered(); + + logger.i('Прокси-мост через loopback создан'); + return clientSide; + } +} + +/// Обёртка над единственной подпиской [RawSocket], с буфером для чтения. +/// +/// После handshake переключается в режим моста: +/// данные из proxy-сокета пересылаются через [onData] в loopback-пару. +class _RawSocketIO { + final RawSocket _socket; + late final StreamSubscription _sub; + + final _readBuffer = []; + Completer? _readWaiter; + Completer? _writeWaiter; + bool _closed = false; + Object? _error; + + /// Коллбэк для данных в режиме моста. + void Function(Uint8List data)? onData; + + /// Коллбэк закрытия в режиме моста. + void Function()? onClosed; + + _RawSocketIO(this._socket) { + _sub = _socket.listen( + _onEvent, + onError: (Object err) { + _error = err; + _closed = true; + _readWaiter?.completeError(err); + _readWaiter = null; + _writeWaiter?.completeError(err); + _writeWaiter = null; + }, + ); + } + + void _onEvent(RawSocketEvent event) { + switch (event) { + case RawSocketEvent.read: + final data = _socket.read(); + if (data != null) { + if (onData != null) { + // Режим моста — пересылаем напрямую + onData!(data); + } else { + // Режим handshake — буферизуем + _readBuffer.addAll(data); + _readWaiter?.complete(); + _readWaiter = null; + } + } + break; + case RawSocketEvent.write: + _writeWaiter?.complete(); + _writeWaiter = null; + break; + case RawSocketEvent.readClosed: + case RawSocketEvent.closed: + _closed = true; + onClosed?.call(); + _readWaiter?.completeError( + SocketException('Прокси закрыл соединение'), + ); + _readWaiter = null; + _writeWaiter?.completeError( + SocketException('Прокси закрыл соединение'), + ); + _writeWaiter = null; + break; + } + } + + /// Читает ровно [count] байт. + Future readExact(int count) async { + while (_readBuffer.length < count) { + if (_error != null) throw _error!; + if (_closed) { + throw SocketException( + 'Соединение закрыто ' + '(ожидали $count байт, получили ${_readBuffer.length})', + ); + } + _readWaiter = Completer(); + await _readWaiter!.future.timeout( + const Duration(seconds: 15), + onTimeout: () => + throw SocketException('Тайм-аут при чтении от прокси'), + ); + } + final result = Uint8List.fromList(_readBuffer.sublist(0, count)); + _readBuffer.removeRange(0, count); + return result; + } + + /// Записывает все байты. + Future write(List data) async { + var offset = 0; + while (offset < data.length) { + if (_error != null) throw _error!; + if (_closed) throw SocketException('Соединение закрыто при записи'); + final written = _socket.write(data, offset); + if (written > 0) { + offset += written; + } else { + _writeWaiter = Completer(); + await _writeWaiter!.future.timeout( + const Duration(seconds: 15), + onTimeout: () => + throw SocketException('Тайм-аут при записи в прокси'), + ); + } + } + } + + /// Пересылает данные, оставшиеся в буфере после handshake, в мост. + void flushBuffered() { + if (_readBuffer.isNotEmpty && onData != null) { + onData!(Uint8List.fromList(_readBuffer)); + _readBuffer.clear(); + } + } + + void dispose() { + _sub.cancel(); + } +} diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index e182159..d5b7784 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -10,6 +10,7 @@ import 'package:komet/l10n/terms_of_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; +import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; import 'spoff_redacted_screen.dart'; import '../../widgets/custom_notification.dart'; @@ -470,6 +471,23 @@ class _LoginScreenState extends State { ); } + void _showProxySettingsSheet(BuildContext context) { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) { + return SafeArea( + child: const ProxySettingsSheet(), + ); + }, + ); + } + void _showSecurityOptions(BuildContext context) { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; @@ -521,6 +539,7 @@ class _LoginScreenState extends State { ), onTap: () { Navigator.pop(sheetContext); + _showProxySettingsSheet(context); }, ), ListTile( diff --git a/lib/frontend/screens/auth/proxy_settings_sheet.dart b/lib/frontend/screens/auth/proxy_settings_sheet.dart new file mode 100644 index 0000000..8553ab1 --- /dev/null +++ b/lib/frontend/screens/auth/proxy_settings_sheet.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:komet/backend/api.dart'; +import 'package:komet/core/config/proxy_config.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; + +class ProxySettingsSheet extends StatefulWidget { + const ProxySettingsSheet({super.key}); + + @override + State createState() => _ProxySettingsSheetState(); +} + +class _ProxySettingsSheetState extends State { + final _hostController = TextEditingController(); + final _portController = TextEditingController(text: '1080'); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + ProxyType _selectedType = ProxyType.none; + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final settings = await ProxyConfig.load(); + if (!mounted) return; + setState(() { + _selectedType = settings.type; + _hostController.text = settings.host; + _portController.text = '${settings.port}'; + _usernameController.text = settings.username ?? ''; + _passwordController.text = settings.password ?? ''; + }); + } + + Future _apply(AppLocalizations l10n) async { + if (_selectedType == ProxyType.none) { + return _disable(l10n); + } + + final host = _hostController.text.trim(); + final port = int.tryParse(_portController.text.trim()); + if (host.isEmpty || port == null || port < 1 || port > 65535) { + showCustomNotification(context, l10n.proxyInvalidHostOrPort); + return; + } + + setState(() => _busy = true); + try { + final username = _usernameController.text.trim(); + final password = _passwordController.text.trim(); + await ProxyConfig.save(ProxySettings( + type: _selectedType, + host: host, + port: port, + username: username.isNotEmpty ? username : null, + password: password.isNotEmpty ? password : null, + )); + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.proxySettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _disable(AppLocalizations l10n) async { + setState(() => _busy = true); + try { + await ProxyConfig.clear(); + setState(() => _selectedType = ProxyType.none); + await api.disconnect(); + await api.connect(); + if (!mounted) return; + if (api.state == SessionState.online) { + showCustomNotification(context, l10n.proxySettingsSaved); + } else { + showCustomNotification(context, l10n.serverReconnectFailed); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + final isActive = _selectedType != ProxyType.none; + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + l10n.proxySettingsTitle, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 20), + + // Proxy type selector + _buildTypeSelector(cs, l10n), + const SizedBox(height: 16), + + // Fields shown only when proxy is enabled + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: isActive + ? Column( + children: [ + _buildTextField( + controller: _hostController, + label: l10n.proxyHostLabel, + hintText: '127.0.0.1', + cs: cs, + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _portController, + label: l10n.proxyPortLabel, + hintText: _selectedType == ProxyType.socks5 + ? '1080' + : '8080', + cs: cs, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + const SizedBox(height: 16), + _buildTextField( + controller: _usernameController, + label: l10n.proxyUsernameLabel, + cs: cs, + keyboardType: TextInputType.text, + ), + const SizedBox(height: 16), + _buildTextField( + controller: _passwordController, + label: l10n.proxyPasswordLabel, + cs: cs, + keyboardType: TextInputType.visiblePassword, + obscureText: true, + ), + const SizedBox(height: 8), + ], + ) + : const SizedBox.shrink(), + ), + + const SizedBox(height: 16), + FilledButton( + onPressed: _busy ? null : () => _apply(l10n), + child: Text( + isActive ? l10n.proxyApply : l10n.proxyDisable, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTypeSelector(ColorScheme cs, AppLocalizations l10n) { + final labels = { + ProxyType.none: l10n.proxyTypeNone, + ProxyType.socks5: l10n.proxyTypeSocks5, + ProxyType.httpConnect: l10n.proxyTypeHttp, + }; + + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(4), + child: Row( + children: ProxyType.values.map((type) { + final selected = _selectedType == type; + return Expanded( + child: GestureDetector( + onTap: _busy ? null : () => setState(() => _selectedType = type), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: selected ? cs.primary : Colors.transparent, + borderRadius: BorderRadius.circular(9), + ), + alignment: Alignment.center, + child: Text( + labels[type]!, + style: GoogleFonts.inter( + color: selected ? cs.onPrimary : cs.onSurfaceVariant, + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + required ColorScheme cs, + String? hintText, + TextInputType? keyboardType, + List? inputFormatters, + bool obscureText = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + enabled: !_busy, + obscureText: obscureText, + style: GoogleFonts.inter( + color: cs.onSurface, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: GoogleFonts.inter( + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + fontSize: 15, + ), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 4a37280..ddaba8e 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; import 'security_screen.dart'; @@ -118,6 +119,28 @@ class _SettingsTabState extends State { icon: Symbols.notifications_active, label: 'Уведомления и звук', ), + _SettingsItem( + icon: Symbols.vpn_lock, + label: 'Прокси', + onTap: () { + final cs = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + builder: (_) { + return SafeArea( + child: const ProxySettingsSheet(), + ); + }, + ); + }, + ), _SettingsItem( icon: Symbols.shield_lock, label: 'Подделка данных', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3d832a0..ae9b610 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -41,5 +41,18 @@ } }, "codeResendSms": "Resend code via SMS", - "codeError2faMissing": "Error: missing data for 2FA" + "codeError2faMissing": "Error: missing data for 2FA", + + "proxySettingsTitle": "Proxy", + "proxyTypeNone": "Disabled", + "proxyTypeSocks5": "SOCKS5", + "proxyTypeHttp": "HTTP(S)", + "proxyHostLabel": "Proxy host", + "proxyPortLabel": "Proxy port", + "proxyUsernameLabel": "Username (optional)", + "proxyPasswordLabel": "Password (optional)", + "proxyApply": "Apply and reconnect", + "proxyDisable": "Disable proxy", + "proxySettingsSaved": "Proxy settings applied", + "proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 05cc593..c029ed3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -307,6 +307,78 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Error: missing data for 2FA'** String get codeError2faMissing; + + /// No description provided for @proxySettingsTitle. + /// + /// In en, this message translates to: + /// **'Proxy'** + String get proxySettingsTitle; + + /// No description provided for @proxyTypeNone. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get proxyTypeNone; + + /// No description provided for @proxyTypeSocks5. + /// + /// In en, this message translates to: + /// **'SOCKS5'** + String get proxyTypeSocks5; + + /// No description provided for @proxyTypeHttp. + /// + /// In en, this message translates to: + /// **'HTTP(S)'** + String get proxyTypeHttp; + + /// No description provided for @proxyHostLabel. + /// + /// In en, this message translates to: + /// **'Proxy host'** + String get proxyHostLabel; + + /// No description provided for @proxyPortLabel. + /// + /// In en, this message translates to: + /// **'Proxy port'** + String get proxyPortLabel; + + /// No description provided for @proxyUsernameLabel. + /// + /// In en, this message translates to: + /// **'Username (optional)'** + String get proxyUsernameLabel; + + /// No description provided for @proxyPasswordLabel. + /// + /// In en, this message translates to: + /// **'Password (optional)'** + String get proxyPasswordLabel; + + /// No description provided for @proxyApply. + /// + /// In en, this message translates to: + /// **'Apply and reconnect'** + String get proxyApply; + + /// No description provided for @proxyDisable. + /// + /// In en, this message translates to: + /// **'Disable proxy'** + String get proxyDisable; + + /// No description provided for @proxySettingsSaved. + /// + /// In en, this message translates to: + /// **'Proxy settings applied'** + String get proxySettingsSaved; + + /// No description provided for @proxyInvalidHostOrPort. + /// + /// In en, this message translates to: + /// **'Enter a valid proxy host and port (1–65535)'** + String get proxyInvalidHostOrPort; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index df7fda7..f7bad3e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -116,4 +116,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get codeError2faMissing => 'Error: missing data for 2FA'; + + @override + String get proxySettingsTitle => 'Proxy'; + + @override + String get proxyTypeNone => 'Disabled'; + + @override + String get proxyTypeSocks5 => 'SOCKS5'; + + @override + String get proxyTypeHttp => 'HTTP(S)'; + + @override + String get proxyHostLabel => 'Proxy host'; + + @override + String get proxyPortLabel => 'Proxy port'; + + @override + String get proxyUsernameLabel => 'Username (optional)'; + + @override + String get proxyPasswordLabel => 'Password (optional)'; + + @override + String get proxyApply => 'Apply and reconnect'; + + @override + String get proxyDisable => 'Disable proxy'; + + @override + String get proxySettingsSaved => 'Proxy settings applied'; + + @override + String get proxyInvalidHostOrPort => + 'Enter a valid proxy host and port (1–65535)'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 6a92039..0524bca 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -118,4 +118,41 @@ class AppLocalizationsRu extends AppLocalizations { @override String get codeError2faMissing => 'Ошибка: отсутствуют данные для 2FA'; + + @override + String get proxySettingsTitle => 'Прокси'; + + @override + String get proxyTypeNone => 'Выключен'; + + @override + String get proxyTypeSocks5 => 'SOCKS5'; + + @override + String get proxyTypeHttp => 'HTTP(S)'; + + @override + String get proxyHostLabel => 'Хост прокси'; + + @override + String get proxyPortLabel => 'Порт прокси'; + + @override + String get proxyUsernameLabel => 'Логин (необязательно)'; + + @override + String get proxyPasswordLabel => 'Пароль (необязательно)'; + + @override + String get proxyApply => 'Применить и переподключиться'; + + @override + String get proxyDisable => 'Отключить прокси'; + + @override + String get proxySettingsSaved => 'Настройки прокси применены'; + + @override + String get proxyInvalidHostOrPort => + 'Укажите корректный хост и порт прокси (1–65535)'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1d92905..babcc9e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -41,5 +41,18 @@ } }, "codeResendSms": "Отправить код по SMS", - "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA" + "codeError2faMissing": "Ошибка: отсутствуют данные для 2FA", + + "proxySettingsTitle": "Прокси", + "proxyTypeNone": "Выключен", + "proxyTypeSocks5": "SOCKS5", + "proxyTypeHttp": "HTTP(S)", + "proxyHostLabel": "Хост прокси", + "proxyPortLabel": "Порт прокси", + "proxyUsernameLabel": "Логин (необязательно)", + "proxyPasswordLabel": "Пароль (необязательно)", + "proxyApply": "Применить и переподключиться", + "proxyDisable": "Отключить прокси", + "proxySettingsSaved": "Настройки прокси применены", + "proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)" } From 94dcd670a393682e99674e77f0ee78683cfdff92 Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 9 Apr 2026 21:55:53 +0300 Subject: [PATCH 48/59] fix(auth): await session online state when applying custom server --- lib/backend/api.dart | 8 +++++--- .../screens/auth/server_settings_sheet.dart | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 7751155..87fd3d6 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -74,9 +74,11 @@ class Api { await _connection.connect(endpoint.host, endpoint.port); } catch (e) { logger.e('Не удалось подключиться: $e'); - _cleanup(); - _setSessionState(SessionState.disconnected); - _scheduleReconnect(); + if (_sessionState != SessionState.disconnected) { + _cleanup(); + _setSessionState(SessionState.disconnected); + _scheduleReconnect(); + } return; } diff --git a/lib/frontend/screens/auth/server_settings_sheet.dart b/lib/frontend/screens/auth/server_settings_sheet.dart index 9c85651..534338a 100644 --- a/lib/frontend/screens/auth/server_settings_sheet.dart +++ b/lib/frontend/screens/auth/server_settings_sheet.dart @@ -53,9 +53,14 @@ class _ServerSettingsSheetState extends State { await prefs.setString(ServerConfig.prefHostKey, host); await prefs.setInt(ServerConfig.prefPortKey, port); await api.disconnect(); - await api.connect(); + api.connect(); + final online = await api.stateStream + .firstWhere((s) => + s == SessionState.online || s == SessionState.disconnected) + .timeout(const Duration(seconds: 15), + onTimeout: () => SessionState.disconnected); if (!mounted) return; - if (api.state == SessionState.online) { + if (online == SessionState.online) { showCustomNotification(context, l10n.serverSettingsSaved); } else { showCustomNotification(context, l10n.serverReconnectFailed); @@ -74,9 +79,14 @@ class _ServerSettingsSheetState extends State { _hostController.text = ServerConfig.defaultHost; _portController.text = '${ServerConfig.defaultPort}'; await api.disconnect(); - await api.connect(); + api.connect(); + final online = await api.stateStream + .firstWhere((s) => + s == SessionState.online || s == SessionState.disconnected) + .timeout(const Duration(seconds: 15), + onTimeout: () => SessionState.disconnected); if (!mounted) return; - if (api.state == SessionState.online) { + if (online == SessionState.online) { showCustomNotification(context, l10n.serverSettingsSaved); } else { showCustomNotification(context, l10n.serverReconnectFailed); From d0e8d649673d64beed06bd5fde6362fa49abfe8d Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 11 Apr 2026 00:00:37 +0700 Subject: [PATCH 49/59] =?UTF-8?q?=D0=B1=D0=BB=D1=8F=20=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D0=B0=D0=B9=D0=B4=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 88 ++++++++++++++++++--- lib/frontend/screens/chats/chat_screen.dart | 58 ++++++++++++++ lib/frontend/widgets/message_bubble.dart | 83 ++++++++++++++++++- lib/models/attachment.dart | 67 ++++++++++++++++ pubspec.lock | 16 ++-- 5 files changed, 288 insertions(+), 24 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 61a2dbb..e9259e9 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1,10 +1,20 @@ import 'dart:convert'; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import '../api.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/storage/app_database.dart'; import '../../models/attachment.dart'; +class ContactCache { + static final Map _cache = {}; + + static void put(int id, String name) { + _cache[id] = name; + } + + static String? get(int id) => _cache[id]; +} + class CachedMessage { final String id; final int accountId; @@ -39,11 +49,20 @@ class CachedMessage { List? attachments; if (payload != null) { - final attaches = payload['attaches'] as List?; - if (attaches != null) { - attachments = attaches - .map((a) => MessageAttachment.fromMap(a as Map)) - .toList(); + final linkType = payload['link']?['type'] as String?; + if (linkType == 'FORWARD') { + attachments = [ForwardedMessageAttachment.fromMap(payload)]; + } else { + final attaches = payload['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .map( + (a) => MessageAttachment.fromMap( + Map.from(a as Map), + ), + ) + .toList(); + } } } @@ -158,13 +177,24 @@ class MessagesModule { final id = m['id']?.toString(); if (id == null) return null; - final attaches = m['attaches'] as List?; + final linkRaw = m['link']; + String? linkType; + if (linkRaw is Map) { + linkType = linkRaw['type'] as String?; + } + List? attachments; - if (attaches != null) { - attachments = attaches - .whereType() - .map((a) => MessageAttachment.fromMap(a.cast())) - .toList(); + if (linkType == 'FORWARD') { + final fwdMap = Map.from(m.cast()); + attachments = [ForwardedMessageAttachment.fromMap(fwdMap)]; + } else { + final attaches = m['attaches'] as List?; + if (attaches != null) { + attachments = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } } return CachedMessage( @@ -175,7 +205,7 @@ class MessagesModule { text: m['text'] as String?, time: (m['time'] as int?) ?? 0, status: m['status'] as String?, - payload: m.cast(), + payload: Map.from(m.cast()), attachments: attachments, ); } @@ -313,4 +343,36 @@ class MessagesModule { return null; } } + + Future searchContactById(int contactId) async { + final cached = ContactCache.get(contactId); + if (cached != null) return cached; + + try { + final response = await _api.sendRequest(Opcode.contactInfo, { + 'id': contactId, + }); + + if (!response.isOk) return null; + final data = response.payload; + if (data is! Map) return null; + + final names = data['names'] as List?; + if (names != null && names.isNotEmpty) { + final name = names.first; + if (name is Map) { + final firstName = name['firstName'] as String? ?? ''; + final lastName = name['lastName'] as String?; + final fullName = lastName != null + ? '$firstName $lastName' + : firstName; + ContactCache.put(contactId, fullName); + return fullName; + } + } + } catch (e) { + debugPrint('searchContactById error: $e'); + } + return null; + } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 2944231..1968d3b 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -5,6 +5,7 @@ import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; +import '../../../models/attachment.dart'; import '../../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { @@ -81,6 +82,7 @@ class _ChatScreenState extends State _isLoading = false; }); } + _loadForwardedSenderNames(); } catch (e) { debugPrint('Error fetching history: $e'); if (mounted) { @@ -164,6 +166,62 @@ class _ChatScreenState extends State } } + Future _loadForwardedSenderNames() async { + final forwardIds = {}; + for (final msg in _messages) { + if (msg.attachments != null) { + for (final a in msg.attachments!) { + if (a is ForwardedMessageAttachment) { + if (a.originalSenderName == null) { + forwardIds.add(a.originalSenderId); + } + } + } + } + } + if (forwardIds.isEmpty) return; + + for (final id in forwardIds) { + final name = await messagesModule.searchContactById(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, + originalMessageId: a.originalMessageId, + originalTime: a.originalTime, + originalText: a.originalText, + originalChatId: a.originalChatId, + originalAttachments: a.originalAttachments, + ); + } + 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, + ); + } + } + }); + } + } + } + void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index c3a5b62..100a1fd 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -78,6 +78,7 @@ class MessageBubble extends StatelessWidget { MessageType get contentType { if (message.attachments != null && message.attachments!.isNotEmpty) { final first = message.attachments!.first; + if (first is ForwardedMessageAttachment) return MessageType.text; if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; return MessageType.attachment; @@ -317,14 +318,26 @@ class MessageBubble extends StatelessWidget { ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final forwarded = _getForwardedAttachment(); + return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( - child: Text( - message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (forwarded != null) ...[ + _buildForwardedHeader(context, forwarded), + const SizedBox(height: 4), + ], + Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), + ], ), ), const SizedBox(width: 8), @@ -343,12 +356,76 @@ class MessageBubble extends StatelessWidget { ); } + ForwardedMessageAttachment? _getForwardedAttachment() { + if (message.attachments == null || message.attachments!.isEmpty) + return null; + for (final a in message.attachments!) { + if (a is ForwardedMessageAttachment) return a; + } + return null; + } + + Widget _buildForwardedHeader( + BuildContext context, + ForwardedMessageAttachment forwarded, + ) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + + final senderName = forwarded.originalSenderName; + final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final origText = forwarded.originalText; + final hasOrigText = origText != null && origText.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (hasOrigText) ...[ + const SizedBox(height: 2), + Text( + origText, + style: TextStyle(color: textColor, fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ); + } + Widget _buildAttachmentContent(BuildContext context) { final attachments = message.attachments; if (attachments == null || attachments.isEmpty) { return _buildTextContent(context); } + final first = attachments.first; + if (first is ForwardedMessageAttachment) { + return _buildTextContent(context); + } + final photos = attachments.whereType().toList(); if (photos.isEmpty) { return _buildGenericAttachment(context, attachments.first); diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index dbaf79e..3da1153 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -433,6 +433,73 @@ class ControlAttachment extends MessageAttachment { }; } +class ForwardedMessageAttachment extends MessageAttachment { + final int originalSenderId; + final String? originalSenderName; + final String? originalMessageId; + final int? originalTime; + final String? originalText; + final int? originalChatId; + final List? originalAttachments; + + const ForwardedMessageAttachment({ + required this.originalSenderId, + this.originalSenderName, + this.originalMessageId, + this.originalTime, + this.originalText, + this.originalChatId, + this.originalAttachments, + }) : super(type: AttachmentType.photo); + + factory ForwardedMessageAttachment.fromMap(Map map) { + final linkRaw = map['link']; + Map? link; + if (linkRaw is Map) { + link = Map.from(linkRaw); + } + + Map? message; + if (link != null) { + final msgRaw = link['message']; + if (msgRaw is Map) { + message = Map.from(msgRaw); + } + } + + List? originalAttaches; + if (message != null) { + final attaches = message['attaches'] as List?; + if (attaches != null) { + originalAttaches = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + } + + return ForwardedMessageAttachment( + originalSenderId: (message?['sender'] as int?) ?? 0, + originalMessageId: message?['id']?.toString(), + originalTime: message?['time'] as int?, + originalText: message?['text'] as String?, + originalChatId: link?['chatId'] as int?, + originalAttachments: originalAttaches, + ); + } + + @override + Map toMap() => { + '_type': 'FORWARD', + 'originalSenderId': originalSenderId, + 'originalSenderName': originalSenderName, + 'originalMessageId': originalMessageId, + 'originalTime': originalTime, + 'originalText': originalText, + 'originalChatId': originalChatId, + }; +} + class UnknownAttachment extends MessageAttachment { final Map rawData; diff --git a/pubspec.lock b/pubspec.lock index b09c9ff..1f7d572 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -313,18 +313,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -638,10 +638,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timezone: dependency: "direct main" description: From 32afe1d85267f5bf84c9373cb4fdf067a54a2f1d Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 11 Apr 2026 00:09:38 +0700 Subject: [PATCH 50/59] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=81=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=84=D0=BE=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/widgets/message_bubble.dart | 171 +++++++++++++++++++---- 1 file changed, 143 insertions(+), 28 deletions(-) diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 100a1fd..352435d 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -78,7 +78,13 @@ class MessageBubble extends StatelessWidget { MessageType get contentType { if (message.attachments != null && message.attachments!.isNotEmpty) { final first = message.attachments!.first; - if (first is ForwardedMessageAttachment) return MessageType.text; + if (first is ForwardedMessageAttachment) { + final fwd = first; + final hasPhoto = + fwd.originalAttachments != null && + fwd.originalAttachments!.any((a) => a is PhotoAttachment); + return hasPhoto ? MessageType.attachment : MessageType.text; + } if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; return MessageType.attachment; @@ -319,26 +325,19 @@ class MessageBubble extends StatelessWidget { : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); final forwarded = _getForwardedAttachment(); + final isForwarded = forwarded != null; return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (forwarded != null) ...[ - _buildForwardedHeader(context, forwarded), - const SizedBox(height: 4), - ], - Text( - message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), - ), - ], - ), + child: isForwarded + ? _buildForwardedInlineText(context, forwarded, textColor) + : Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), ), const SizedBox(width: 8), Padding( @@ -356,6 +355,58 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildForwardedInlineText( + BuildContext context, + ForwardedMessageAttachment forwarded, + Color textColor, + ) { + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.onSurfaceVariant; + + final senderName = forwarded.originalSenderName; + final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final origText = forwarded.originalText; + final hasOrigText = origText != null && origText.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (hasOrigText) ...[ + const SizedBox(height: 2), + Text( + origText, + style: TextStyle(color: textColor, fontSize: 14), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ] else ...[ + const SizedBox(height: 2), + Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), + ], + ], + ); + } + ForwardedMessageAttachment? _getForwardedAttachment() { if (message.attachments == null || message.attachments!.isEmpty) return null; @@ -387,20 +438,23 @@ class MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Symbols.forward, size: 14, color: headerColor), - const SizedBox(width: 4), - Text( - displaySender, - style: TextStyle( - color: headerColor, - fontSize: 12, - fontWeight: FontWeight.w500, + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), ), - ), - ], + ], + ), ), if (hasOrigText) ...[ const SizedBox(height: 2), @@ -423,6 +477,13 @@ class MessageBubble extends StatelessWidget { final first = attachments.first; if (first is ForwardedMessageAttachment) { + final fwd = first; + final photos = fwd.originalAttachments + ?.whereType() + .toList(); + if (photos != null && photos.isNotEmpty) { + return _buildForwardedPhotoContent(context, fwd, photos); + } return _buildTextContent(context); } @@ -514,6 +575,60 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildForwardedPhotoContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List photos, + ) { + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.onSurfaceVariant; + final senderName = forwarded.originalSenderName; + final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final hasCaption = message.text != null && message.text!.isNotEmpty; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + if (hasCaption) ...[ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + message.text ?? '', + style: TextStyle( + color: isMe ? Colors.white : const Color(0xFF1C1C1E), + fontSize: 16, + height: 1.3, + ), + ), + ), + const SizedBox(height: 6), + ], + _buildPhotoContent(context, photos), + ], + ); + } + Widget _buildSinglePhoto(BuildContext ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; final width = photo.width?.toDouble() ?? 200; From d3a001ddecb6d896671754806bd6f83051910ee1 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 11 Apr 2026 00:17:28 +0700 Subject: [PATCH 51/59] =?UTF-8?q?=D0=91=D0=BB=D1=8F=20=D0=BB=D0=B5=D1=88?= =?UTF-8?q?=D0=B0=20=D1=8F=20=D1=82=D0=B2=D0=BE=D0=B8=20=D0=BF=D1=8F=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=86=D0=B5=D0=BB=D0=BE=D0=B2=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index e9259e9..13f8af2 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -350,24 +350,30 @@ class MessagesModule { try { final response = await _api.sendRequest(Opcode.contactInfo, { - 'id': contactId, + 'contactIds': [contactId], }); if (!response.isOk) return null; final data = response.payload; if (data is! Map) return null; - final names = data['names'] as List?; - if (names != null && names.isNotEmpty) { - final name = names.first; - if (name is Map) { - final firstName = name['firstName'] as String? ?? ''; - final lastName = name['lastName'] as String?; - final fullName = lastName != null - ? '$firstName $lastName' - : firstName; - ContactCache.put(contactId, fullName); - return fullName; + final contacts = data['contacts'] as List?; + if (contacts != null && contacts.isNotEmpty) { + final contact = contacts.first; + if (contact is Map) { + final names = contact['names'] as List?; + if (names != null && names.isNotEmpty) { + final name = names.first; + if (name is Map) { + final firstName = name['firstName'] as String? ?? ''; + final lastName = name['lastName'] as String?; + final fullName = lastName != null + ? '$firstName $lastName' + : firstName; + ContactCache.put(contactId, fullName); + return fullName; + } + } } } } catch (e) { From 8536ec25084d25096fcb682c0abf3fbc7607aaa8 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 11 Apr 2026 00:22:32 +0700 Subject: [PATCH 52/59] =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=BE4=D0=BA=D0=B8=20=D0=B2=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 18 +++++++-- lib/frontend/screens/chats/chat_screen.dart | 3 ++ lib/frontend/widgets/message_bubble.dart | 44 ++++++++++++++++++++- lib/l10n/app_localizations.dart | 37 ++++++++--------- lib/l10n/app_localizations_en.dart | 9 ++--- lib/l10n/app_localizations_ru.dart | 15 +++---- lib/models/attachment.dart | 3 ++ 7 files changed, 87 insertions(+), 42 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 13f8af2..00dd1c0 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -6,13 +6,21 @@ import '../../core/storage/app_database.dart'; import '../../models/attachment.dart'; class ContactCache { - static final Map _cache = {}; + static final Map _nameCache = {}; + static final Map _avatarCache = {}; static void put(int id, String name) { - _cache[id] = name; + _nameCache[id] = name; } - static String? get(int id) => _cache[id]; + static void putAvatar(int id, String? baseUrl) { + if (baseUrl != null) { + _avatarCache[id] = baseUrl; + } + } + + static String? get(int id) => _nameCache[id]; + static String? getAvatar(int id) => _avatarCache[id]; } class CachedMessage { @@ -371,6 +379,10 @@ class MessagesModule { ? '$firstName $lastName' : firstName; ContactCache.put(contactId, fullName); + + final baseUrl = contact['baseUrl'] as String?; + ContactCache.putAvatar(contactId, baseUrl); + return fullName; } } diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 1968d3b..a05c853 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -6,6 +6,7 @@ import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; import '../../../core/storage/app_database.dart'; import '../../../models/attachment.dart'; +import '../../../backend/modules/messages.dart' show ContactCache; import '../../widgets/message_bubble.dart'; class ChatScreen extends StatefulWidget { @@ -183,6 +184,7 @@ class _ChatScreenState extends State 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++) { @@ -195,6 +197,7 @@ class _ChatScreenState extends State return ForwardedMessageAttachment( originalSenderId: id, originalSenderName: name, + originalSenderAvatar: avatar, originalMessageId: a.originalMessageId, originalTime: a.originalTime, originalText: a.originalText, diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 352435d..647da92 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -360,12 +360,14 @@ class MessageBubble extends StatelessWidget { ForwardedMessageAttachment forwarded, Color textColor, ) { + final cs = Theme.of(context).colorScheme; final headerColor = isMe ? Colors.white.withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurfaceVariant; + : cs.onSurfaceVariant; final senderName = forwarded.originalSenderName; final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; final origText = forwarded.originalText; final hasOrigText = origText != null && origText.isNotEmpty; @@ -378,6 +380,24 @@ class MessageBubble extends StatelessWidget { children: [ Icon(Symbols.forward, size: 14, color: headerColor), const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), Text( displaySender, style: TextStyle( @@ -580,11 +600,13 @@ class MessageBubble extends StatelessWidget { ForwardedMessageAttachment forwarded, List photos, ) { + final cs = Theme.of(context).colorScheme; final headerColor = isMe ? Colors.white.withValues(alpha: 0.7) - : Theme.of(context).colorScheme.onSurfaceVariant; + : cs.onSurfaceVariant; final senderName = forwarded.originalSenderName; final displaySender = senderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; final hasCaption = message.text != null && message.text!.isNotEmpty; return Column( @@ -598,6 +620,24 @@ class MessageBubble extends StatelessWidget { children: [ Icon(Symbols.forward, size: 14, color: headerColor), const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), Text( displaySender, style: TextStyle( diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c029ed3..6741f70 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -62,8 +62,7 @@ import 'app_localizations_ru.dart'; /// be consistent with the languages listed in the AppLocalizations.supportedLocales /// property. abstract class AppLocalizations { - AppLocalizations(String locale) - : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -71,8 +70,7 @@ abstract class AppLocalizations { return Localizations.of(context, AppLocalizations); } - static const LocalizationsDelegate delegate = - _AppLocalizationsDelegate(); + static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); /// A list of this localizations delegate along with the default localizations /// delegates. @@ -84,18 +82,17 @@ abstract class AppLocalizations { /// Additional delegates can be added by appending to this list in /// MaterialApp. This list does not have to be used at all if a custom list /// of delegates is preferred or required. - static const List> localizationsDelegates = - >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + static const List> localizationsDelegates = >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ Locale('en'), - Locale('ru'), + Locale('ru') ]; /// No description provided for @loginTitle. @@ -381,8 +378,7 @@ abstract class AppLocalizations { String get proxyInvalidHostOrPort; } -class _AppLocalizationsDelegate - extends LocalizationsDelegate { +class _AppLocalizationsDelegate extends LocalizationsDelegate { const _AppLocalizationsDelegate(); @override @@ -391,26 +387,25 @@ class _AppLocalizationsDelegate } @override - bool isSupported(Locale locale) => - ['en', 'ru'].contains(locale.languageCode); + bool isSupported(Locale locale) => ['en', 'ru'].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; } AppLocalizations lookupAppLocalizations(Locale locale) { + + // Lookup logic when only language code is specified. switch (locale.languageCode) { - case 'en': - return AppLocalizationsEn(); - case 'ru': - return AppLocalizationsRu(); + case 'en': return AppLocalizationsEn(); + case 'ru': return AppLocalizationsRu(); } throw FlutterError( 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' 'an issue with the localizations generation tool. Please file an issue ' 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.', + 'that was used.' ); } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f7bad3e..68d2d21 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -12,8 +12,7 @@ class AppLocalizationsEn extends AppLocalizations { String get loginTitle => 'Sign in to Komet'; @override - String get loginSubtitle => - 'Check your country code and enter your\nphone number.'; + String get loginSubtitle => 'Check your country code and enter your\nphone number.'; @override String get loginCountry => 'Country'; @@ -103,8 +102,7 @@ class AppLocalizationsEn extends AppLocalizations { String get selectCountrySearchHint => 'Search countries…'; @override - String get codeConfirmationSmsSent => - 'We sent an SMS with a verification code to your phone number.'; + String get codeConfirmationSmsSent => 'We sent an SMS with a verification code to your phone number.'; @override String codeResendInSeconds(int seconds) { @@ -151,6 +149,5 @@ class AppLocalizationsEn extends AppLocalizations { String get proxySettingsSaved => 'Proxy settings applied'; @override - String get proxyInvalidHostOrPort => - 'Enter a valid proxy host and port (1–65535)'; + String get proxyInvalidHostOrPort => 'Enter a valid proxy host and port (1–65535)'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 0524bca..7f58d45 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -12,8 +12,7 @@ class AppLocalizationsRu extends AppLocalizations { String get loginTitle => 'Войдите в Komet'; @override - String get loginSubtitle => - 'Проверьте код страны и введите свой\nномер телефона.'; + String get loginSubtitle => 'Проверьте код страны и введите свой\nномер телефона.'; @override String get loginCountry => 'Страна'; @@ -43,8 +42,7 @@ class AppLocalizationsRu extends AppLocalizations { String get loginDone => 'Готово'; @override - String get loginReadTermsNotification => - 'Сначала прочитайте условия использования'; + String get loginReadTermsNotification => 'Сначала прочитайте условия использования'; @override String get loginSpoofRedacted => 'Подделка спуфа'; @@ -71,8 +69,7 @@ class AppLocalizationsRu extends AppLocalizations { String get serverUseDefault => 'Сбросить к умолчанию'; @override - String get serverInvalidHostOrPort => - 'Укажите корректный хост и порт (1–65535)'; + String get serverInvalidHostOrPort => 'Укажите корректный хост и порт (1–65535)'; @override String get serverSettingsSaved => 'Настройки сервера применены'; @@ -105,8 +102,7 @@ class AppLocalizationsRu extends AppLocalizations { String get selectCountrySearchHint => 'Поиск страны…'; @override - String get codeConfirmationSmsSent => - 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.'; + String get codeConfirmationSmsSent => 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.'; @override String codeResendInSeconds(int seconds) { @@ -153,6 +149,5 @@ class AppLocalizationsRu extends AppLocalizations { String get proxySettingsSaved => 'Настройки прокси применены'; @override - String get proxyInvalidHostOrPort => - 'Укажите корректный хост и порт прокси (1–65535)'; + String get proxyInvalidHostOrPort => 'Укажите корректный хост и порт прокси (1–65535)'; } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 3da1153..2c43222 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -436,6 +436,7 @@ class ControlAttachment extends MessageAttachment { class ForwardedMessageAttachment extends MessageAttachment { final int originalSenderId; final String? originalSenderName; + final String? originalSenderAvatar; final String? originalMessageId; final int? originalTime; final String? originalText; @@ -445,6 +446,7 @@ class ForwardedMessageAttachment extends MessageAttachment { const ForwardedMessageAttachment({ required this.originalSenderId, this.originalSenderName, + this.originalSenderAvatar, this.originalMessageId, this.originalTime, this.originalText, @@ -493,6 +495,7 @@ class ForwardedMessageAttachment extends MessageAttachment { '_type': 'FORWARD', 'originalSenderId': originalSenderId, 'originalSenderName': originalSenderName, + 'originalSenderAvatar': originalSenderAvatar, 'originalMessageId': originalMessageId, 'originalTime': originalTime, 'originalText': originalText, From 23ef5870153740c5b5d9d500e399a4fe93b2d2b1 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 11 Apr 2026 00:21:31 +0300 Subject: [PATCH 53/59] feat(spoof): port session spoofing screen with device presets --- lib/backend/api.dart | 53 +- lib/core/config/device_presets.dart | 1013 +++++++++++++++++ lib/core/config/spoof_data.dart | 31 - lib/core/storage/spoofing_service.dart | 27 + lib/frontend/screens/auth/login_screen.dart | 4 +- .../screens/auth/spoff_redacted_screen.dart | 217 ---- .../screens/profile/settings_tab.dart | 3 +- .../screens/profile/spoof_screen.dart | 980 ++++++++++++---- lib/l10n/app_en.arb | 48 +- lib/l10n/app_localizations.dart | 255 ++++- lib/l10n/app_localizations_en.dart | 127 ++- lib/l10n/app_localizations_ru.dart | 133 ++- lib/l10n/app_ru.arb | 48 +- 13 files changed, 2398 insertions(+), 541 deletions(-) create mode 100644 lib/core/config/device_presets.dart delete mode 100644 lib/core/config/spoof_data.dart create mode 100644 lib/core/storage/spoofing_service.dart delete mode 100644 lib/frontend/screens/auth/spoff_redacted_screen.dart diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 87fd3d6..0ff3ace 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -5,6 +5,7 @@ import '../core/config/config.dart'; import '../core/config/countries.dart'; import '../core/protocol/opcode_map.dart'; import '../core/protocol/packet.dart'; +import '../core/storage/spoofing_service.dart'; import '../core/transport/connection.dart'; import '../core/transport/dispatcher.dart'; import '../core/transport/receiver.dart'; @@ -112,7 +113,7 @@ class Api { Future sendHandshake() async { final deviceInfo = DeviceInfoPlugin(); - final deviceType = (Platform.isLinux || Platform.isWindows) + String deviceType = (Platform.isLinux || Platform.isWindows) ? 'DESKTOP' : (Platform.isAndroid) ? 'ANDROID' @@ -120,10 +121,16 @@ class Api { String osVersion = ''; String deviceName = 'Unknown'; String architecture = 'arm64'; + String appVersion = SpoofingService.hardcodedAppVersion; + int buildNumber = SpoofingService.hardcodedBuildNumber; + String screen = '1920x1080'; tz.initializeTimeZones(); final timeZoneName = await FlutterTimezone.getLocalTimezone(); - final timezone = timeZoneName.identifier; + String timezone = timeZoneName.identifier; + String locale = 'ru'; + String deviceLocale = Platform.localeName.substring(0, 2); + String deviceId = 'a1b2c3d4e5f6a7b8'; if (Platform.isLinux) { final linuxInfo = await deviceInfo.linuxInfo; @@ -150,24 +157,54 @@ class Api { ); } + final spoofed = await SpoofingService.getSpoofedSessionData(); + if (spoofed != null) { + deviceType = (spoofed['device_type'] as String?) ?? deviceType; + final sDeviceName = spoofed['device_name'] as String?; + if (sDeviceName != null && sDeviceName.isNotEmpty) { + deviceName = sDeviceName; + } + final sOsVersion = spoofed['os_version'] as String?; + if (sOsVersion != null && sOsVersion.isNotEmpty) osVersion = sOsVersion; + final sScreen = spoofed['screen'] as String?; + if (sScreen != null && sScreen.isNotEmpty) screen = sScreen; + final sTimezone = spoofed['timezone'] as String?; + if (sTimezone != null && sTimezone.isNotEmpty) timezone = sTimezone; + final sLocale = spoofed['locale'] as String?; + if (sLocale != null && sLocale.isNotEmpty) { + locale = sLocale; + deviceLocale = sLocale.split(RegExp(r'[-_]')).first; + } + final sDeviceId = spoofed['device_id'] as String?; + if (sDeviceId != null && sDeviceId.isNotEmpty) deviceId = sDeviceId; + appVersion = (spoofed['app_version'] as String?) ?? appVersion; + architecture = (spoofed['arch'] as String?) ?? architecture; + final sBuild = spoofed['build_number']; + if (sBuild is int) { + buildNumber = sBuild; + } else if (sBuild is String) { + buildNumber = int.tryParse(sBuild) ?? buildNumber; + } + } + _userAgent = { 'deviceType': deviceType, - 'locale': 'ru', - 'deviceLocale': Platform.localeName.substring(0, 2), + 'locale': locale, + 'deviceLocale': deviceLocale, 'osVersion': osVersion, 'deviceName': deviceName, - 'appVersion': '26.8.1', - 'screen': '1920x1080', + 'appVersion': appVersion, + 'screen': screen, 'timezone': timezone, 'pushDeviceType': 'GCM', 'arch': architecture, - 'buildNumber': 6606, + 'buildNumber': buildNumber, }; final payload = { 'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000', 'clientSessionId': 42, - 'deviceId': 'a1b2c3d4e5f6a7b8', + 'deviceId': deviceId, 'userAgent': _userAgent, }; diff --git a/lib/core/config/device_presets.dart b/lib/core/config/device_presets.dart new file mode 100644 index 0000000..de7506e --- /dev/null +++ b/lib/core/config/device_presets.dart @@ -0,0 +1,1013 @@ +class DevicePreset { + final String deviceType; + final String userAgent; + final String deviceName; + final String osVersion; + final String screen; + final String timezone; + final String locale; + + DevicePreset({ + required this.deviceType, + required this.userAgent, + required this.deviceName, + required this.osVersion, + required this.screen, + required this.timezone, + required this.locale, + }); +} + +final List devicePresets = [ + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24 Ultra', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 8 Pro', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1344x2992', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; 23021RAA2Y) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 13 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; CPH2521) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 12', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3168', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S21 Ultra', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 6', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'America/Chicago', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; RMX3371) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Realme GT Master Edition', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 11; M2101K6G) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36', + deviceName: 'Poco F3', + osVersion: 'Android 11', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SO-51D) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Sony Xperia 1 V', + osVersion: 'Android 14', + screen: 'xxxhdpi 560dpi 1644x3840', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; XT2201-2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Motorola Edge 30 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-A546E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy A54', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2340', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; 2201116SG) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Redmi Note 11 Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; ZS676KS) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Asus ROG Phone 6', + osVersion: 'Android 13', + screen: 'xxhdpi 420dpi 1080x2448', + timezone: 'Asia/Taipei', + locale: 'zh-TW', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 10; TA-1021) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36', + deviceName: 'Nokia 8', + osVersion: 'Android 10', + screen: 'xxhdpi 380dpi 1440x2560', + timezone: 'Europe/Helsinki', + locale: 'fi-FI', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; PGT-N19) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Huawei P60 Pro', + osVersion: 'Android 13 (EMUI)', + screen: 'xxhdpi 430dpi 1220x2700', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 9; LM-G710) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Mobile Safari/537.36', + deviceName: 'LG G7 ThinQ', + osVersion: 'Android 9', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'Asia/Seoul', + locale: 'ko-KR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Nothing Phone (2)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Nothing Phone (2)', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2412', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-F936U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy Z Fold 4', + osVersion: 'Android 13', + screen: 'xhdpi 350dpi 1812x2176', + timezone: 'America/Denver', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; LE2113) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 9', + osVersion: 'Android 12', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; Pixel 7a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 7a', + osVersion: 'Android 14', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Europe/Amsterdam', + locale: 'nl-NL', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3120', + timezone: 'America/Vancouver', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; M2101K6C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Poco F3 GT', + osVersion: 'Android 13', + screen: 'xxhdpi 420dpi 1080x2400', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; V29) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Vivo V29', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Bangkok', + locale: 'th-TH', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; K30 Ultra) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi K30 Ultra', + osVersion: 'Android 13', + screen: 'xxhdpi 450dpi 1440x3200', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; P80) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Oppo Find N3', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1440x3168', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; SM-G916B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S20 FE', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; CPH2135) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 8 Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 450dpi 1440x3168', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; S24E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy S24 Edge', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; LE2120) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 9 Pro', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3216', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; A14 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Google Pixel 9 Pro', + osVersion: 'Android 14', + screen: 'xxhdpi 430dpi 1344x2992', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; 21091116AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 12T', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; SM-F711B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy Z Flip 3', + osVersion: 'Android 12', + screen: 'xhdpi 370dpi 1080x2640', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; XT2201-3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Motorola Edge 40', + osVersion: 'Android 13', + screen: 'xxhdpi 400dpi 1080x2400', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; 23088RA9AC) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi 14 Ultra', + osVersion: 'Android 14', + screen: 'xxhdpi 450dpi 1440x3200', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; CPH2487) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36', + deviceName: 'OnePlus 11', + osVersion: 'Android 13', + screen: 'xxhdpi 450dpi 1440x3216', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 12; M2004J19C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + deviceName: 'Xiaomi Mi 10T Pro', + osVersion: 'Android 12', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 14; SM-A546B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + deviceName: 'Samsung Galaxy A55', + osVersion: 'Android 14', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + DevicePreset( + deviceType: 'ANDROID', + userAgent: + 'Mozilla/5.0 (Linux; Android 13; RMX3761) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36', + deviceName: 'Realme GT Neo 3', + osVersion: 'Android 13', + screen: 'xxhdpi 460dpi 1440x3200', + timezone: 'Asia/Hong_Kong', + locale: 'zh-HK', + ), + + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 15 Pro Max', + osVersion: 'iOS 17.5.1', + screen: '1290x2796 3.0x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13', + osVersion: 'iOS 16.7', + screen: '1170x2532 3.0x', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.88 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Pro 11-inch', + osVersion: 'iPadOS 17.5', + screen: '1668x2388 2.0x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/125.0 Mobile/15E148', + deviceName: 'iPhone 14 Pro', + osVersion: 'iOS 17.4.1', + screen: '1179x2556 3.0x', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone SE (2020)', + osVersion: 'iOS 15.8', + screen: '750x1334 2.0x', + timezone: 'Australia/Melbourne', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPR/55.0.2519.144889 Mobile/15E148', + deviceName: 'iPhone 15', + osVersion: 'iOS 17.1', + screen: '1179x2556 3.0x', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air 5th Gen', + osVersion: 'iPadOS 16.5', + screen: '1640x2360 2.0x', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 15 Pro', + osVersion: 'iOS 17.5', + screen: '1179x2556 3.0x', + timezone: 'Asia/Singapore', + locale: 'en-SG', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 14', + osVersion: 'iOS 17.4', + screen: '1170x2532 3.0x', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12 Pro Max', + osVersion: 'iOS 16.6', + screen: '1284x2778 3.0x', + timezone: 'Asia/Bangkok', + locale: 'th-TH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 11 Pro', + osVersion: 'iOS 15.7', + screen: '1125x2436 3.0x', + timezone: 'Europe/Istanbul', + locale: 'tr-TR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Mini 6th Gen', + osVersion: 'iPadOS 17.5', + screen: '1488x2266 2.0x', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 16_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.7 Mobile/15E148 Safari/604.1', + deviceName: 'iPad 10th Gen', + osVersion: 'iPadOS 16.7', + screen: '1620x2160 2.0x', + timezone: 'Asia/Hong_Kong', + locale: 'zh-HK', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13 Pro', + osVersion: 'iOS 17.3', + screen: '1170x2532 3.0x', + timezone: 'Europe/Dublin', + locale: 'en-IE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12', + osVersion: 'iOS 16.5', + screen: '1125x2436 3.0x', + timezone: 'Asia/Mumbai', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Pro 12.9-inch', + osVersion: 'iPadOS 15.8', + screen: '2048x2732 2.0x', + timezone: 'Europe/Vienna', + locale: 'de-AT', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone XS Max', + osVersion: 'iOS 14.8', + screen: '1125x2436 3.0x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 13 mini', + osVersion: 'iOS 17.2', + screen: '1080x2340 3.0x', + timezone: 'America/Miami', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air 4th Gen', + osVersion: 'iPadOS 14.8', + screen: '1640x2360 2.0x', + timezone: 'Europe/Zurich', + locale: 'de-CH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 11', + osVersion: 'iOS 16.4', + screen: '828x1792 2.0x', + timezone: 'America/Argentina/Buenos_Aires', + locale: 'es-AR', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone 12 mini', + osVersion: 'iOS 17.1', + screen: '1080x2340 3.0x', + timezone: 'Europe/Brussels', + locale: 'nl-BE', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + deviceName: 'iPad Air Pro 11-inch', + osVersion: 'iPadOS 17.4', + screen: '2388x1668 2.0x', + timezone: 'Asia/Bangkok', + locale: 'en-TH', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPhone XR', + osVersion: 'iOS 13.7', + screen: '828x1792 2.0x', + timezone: 'Europe/Lisbon', + locale: 'pt-PT', + ), + DevicePreset( + deviceType: 'IOS', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1', + deviceName: 'iPad (9th generation)', + osVersion: 'iPadOS 17.3', + screen: '1620x2160 2.0x', + timezone: 'Europe/Prague', + locale: 'cs-CZ', + ), + + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Windows PC', + osVersion: 'Windows 11', + screen: '1920x1080 1.25x', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'MacBook Pro', + osVersion: 'macOS 14.5 Sonoma', + screen: '1728x1117 2.0x', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Linux PC', + osVersion: 'Ubuntu 24.04 LTS', + screen: '2560x1440 1.0x', + timezone: 'UTC', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Windows PC (Firefox)', + osVersion: 'Windows 10', + screen: '1536x864 1.0x', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'DESKTOP', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15', + deviceName: 'iMac (Safari)', + osVersion: 'macOS 13.6 Ventura', + screen: '3840x2160 1.5x', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows ', + screen: '1920x1080', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '2560x1440', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.2420.97', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1536x864', + timezone: 'Europe/London', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '1920x1200', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Windows', + screen: '1366x768', + timezone: 'Europe/Madrid', + locale: 'es-ES', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'Europe/Rome', + locale: 'it-IT', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1440x900', + timezone: 'Europe/Amsterdam', + locale: 'nl-NL', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 6.3; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1600x900', + timezone: 'Europe/Warsaw', + locale: 'pl-PL', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'America/Chicago', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.109', + deviceName: 'Edge', + osVersion: 'Windows', + screen: '1366x768', + timezone: 'America/Sao_Paulo', + locale: 'pt-BR', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 14.5', + screen: '2560x1440', + timezone: 'America/Los_Angeles', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 13.6', + screen: '1440x900', + timezone: 'America/Toronto', + locale: 'en-CA', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_7_10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 11.7', + screen: '1728x1117', + timezone: 'Australia/Sydney', + locale: 'en-AU', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'macOS 12.5', + screen: '2048x1152', + timezone: 'Europe/London', + locale: 'en-GB', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'macOS 14.5', + screen: '1920x1080', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'macOS 13.0', + screen: '1680x1050', + timezone: 'Europe/Berlin', + locale: 'de-DE', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 14.5', + screen: '1440x900', + timezone: 'America/New_York', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 13.6', + screen: '2560x1600', + timezone: 'Europe/Paris', + locale: 'fr-FR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 10.14', + screen: '1280x800', + timezone: 'Asia/Tokyo', + locale: 'ja-JP', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'Europe/Moscow', + locale: 'ru-RU', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1366x768', + timezone: 'Asia/Kolkata', + locale: 'en-IN', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Chrome OS', + screen: '1920x1080', + timezone: 'America/Mexico_City', + locale: 'es-MX', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1600x900', + timezone: 'Asia/Shanghai', + locale: 'zh-CN', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'UTC', + locale: 'en-GB', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '2560x1440', + timezone: 'America/Denver', + locale: 'en-US', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0', + deviceName: 'Firefox', + osVersion: 'Linux', + screen: '1366x768', + timezone: 'Asia/Dubai', + locale: 'ar-AE', + ), + + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0', + deviceName: 'Opera', + osVersion: 'Windows', + screen: '1920x1080', + timezone: 'Europe/Oslo', + locale: 'no-NO', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Vivaldi/6.5.3206.63', + deviceName: 'Vivaldi', + osVersion: 'macOS 14.0', + screen: '1440x900', + timezone: 'Europe/Stockholm', + locale: 'sv-SE', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0', + deviceName: 'Firefox', + osVersion: 'Windows', + screen: '1280x720', + timezone: 'Asia/Seoul', + locale: 'ko-KR', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', + deviceName: 'Chrome', + osVersion: 'Linux', + screen: '1920x1080', + timezone: 'Europe/Helsinki', + locale: 'fi-FI', + ), + DevicePreset( + deviceType: 'WEB', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15', + deviceName: 'Safari', + osVersion: 'macOS 10.13', + screen: '1280x800', + timezone: 'America/Vancouver', + locale: 'en-CA', + ), +]; diff --git a/lib/core/config/spoof_data.dart b/lib/core/config/spoof_data.dart deleted file mode 100644 index f60c3ff..0000000 --- a/lib/core/config/spoof_data.dart +++ /dev/null @@ -1,31 +0,0 @@ -class SpoofData { - static const List deviceNames = [ - 'Samsung Galaxy S23', - 'Xiaomi 13 Pro', - 'Google Pixel 7', - 'OnePlus 11', - ]; - - static const List osVersions = ['12', '13', '14']; - - static const List resolutions = [ - '1080x2400', - '1440x3200', - '720x1600', - ]; - - static const List deviceIds = [ - 'a1b2c3d4e5f6', - 'f8e7d6c5b4a3', - '9876543210ab', - '1234567890cd', - ]; - - static const List architectures = ['arm64-v8a', 'armeabi-v7a']; - - static const String deviceType = 'android'; - static const String timezone = 'Europe/Moscow'; - static const String locale = 'ru_RU'; - static const String appVersion = '26.10.1'; - static const String buildNumber = '6728'; -} diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart new file mode 100644 index 0000000..21a766d --- /dev/null +++ b/lib/core/storage/spoofing_service.dart @@ -0,0 +1,27 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class SpoofingService { + static const String hardcodedAppVersion = '26.8.1'; + static const int hardcodedBuildNumber = 6606; + + static Future?> getSpoofedSessionData() async { + final prefs = await SharedPreferences.getInstance(); + + final isEnabled = prefs.getBool('spoofing_enabled') ?? false; + if (!isEnabled) return null; + + return { + 'device_name': prefs.getString('spoof_devicename'), + 'os_version': prefs.getString('spoof_osversion'), + 'screen': prefs.getString('spoof_screen'), + 'timezone': prefs.getString('spoof_timezone'), + 'locale': prefs.getString('spoof_locale'), + 'device_id': prefs.getString('spoof_deviceid'), + 'device_type': prefs.getString('spoof_devicetype'), + 'app_version': prefs.getString('spoof_appversion') ?? hardcodedAppVersion, + 'arch': prefs.getString('spoof_arch') ?? 'arm64-v8a', + 'build_number': + prefs.getInt('spoof_buildnumber') ?? hardcodedBuildNumber, + }; + } +} diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index d5b7784..c64d751 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -12,7 +12,7 @@ import 'code_confirmation_screen.dart'; import 'select_country_screen.dart'; import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; -import 'spoff_redacted_screen.dart'; +import '../profile/spoof_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../../main.dart'; @@ -522,7 +522,7 @@ class _LoginScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => const SpoffRedactedScreen(), + builder: (context) => const SpoofScreen(), ), ); }, diff --git a/lib/frontend/screens/auth/spoff_redacted_screen.dart b/lib/frontend/screens/auth/spoff_redacted_screen.dart deleted file mode 100644 index a553a59..0000000 --- a/lib/frontend/screens/auth/spoff_redacted_screen.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'dart:math'; -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:komet/core/config/spoof_data.dart'; -import 'package:komet/frontend/widgets/custom_notification.dart'; - -class SpoffRedactedScreen extends StatefulWidget { - const SpoffRedactedScreen({super.key}); - - @override - State createState() => _SpoffRedactedScreenState(); -} - -class _SpoffRedactedScreenState extends State { - final TextEditingController _deviceNameController = TextEditingController(); - final TextEditingController _osVersionController = TextEditingController(); - final TextEditingController _resolutionController = TextEditingController(); - final TextEditingController _deviceIdController = TextEditingController(); - final TextEditingController _architectureController = TextEditingController(); - - @override - void initState() { - super.initState(); - _loadSettings(); - } - - Future _loadSettings() async { - final prefs = await SharedPreferences.getInstance(); - - final random = Random(); - - setState(() { - _deviceNameController.text = - prefs.getString('spoof_device_name') ?? - SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; - _osVersionController.text = - prefs.getString('spoof_os_version') ?? - SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]; - _resolutionController.text = - prefs.getString('spoof_resolution') ?? - SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; - _deviceIdController.text = - prefs.getString('spoof_device_id') ?? - SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; - _architectureController.text = - prefs.getString('spoof_architecture') ?? - SpoofData.architectures[random.nextInt( - SpoofData.architectures.length, - )]; - }); - } - - Future _saveSettings() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('spoof_device_name', _deviceNameController.text); - await prefs.setString('spoof_os_version', _osVersionController.text); - await prefs.setString('spoof_resolution', _resolutionController.text); - await prefs.setString('spoof_device_id', _deviceIdController.text); - await prefs.setString('spoof_architecture', _architectureController.text); - - if (mounted) { - showCustomNotification(context, 'Настройки сохранены'); - } - } - - @override - void dispose() { - _deviceNameController.dispose(); - _osVersionController.dispose(); - _resolutionController.dispose(); - _deviceIdController.dispose(); - _architectureController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: cs.surface, - appBar: AppBar( - iconTheme: IconThemeData(color: cs.onSurface), - title: Text( - 'Подделка спуфа', - style: GoogleFonts.inter( - color: cs.onSurface, - fontWeight: FontWeight.w500, - ), - ), - backgroundColor: cs.surface, - actions: [ - IconButton( - icon: Icon(Icons.check, color: cs.primary), - onPressed: _saveSettings, - ), - ], - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - _buildTextField( - controller: TextEditingController(text: SpoofData.deviceType), - label: 'Тип устройства', - cs: cs, - readOnly: true, - ), - const SizedBox(height: 16), - _buildTextField( - controller: _deviceNameController, - label: 'Имя устройства', - cs: cs, - ), - const SizedBox(height: 16), - _buildTextField( - controller: _osVersionController, - label: 'Версия ОС', - cs: cs, - ), - const SizedBox(height: 16), - _buildTextField( - controller: _resolutionController, - label: 'Разрешение экрана', - cs: cs, - ), - const SizedBox(height: 16), - _buildTextField( - controller: TextEditingController(text: SpoofData.timezone), - label: 'Часовой пояс', - cs: cs, - readOnly: true, - ), - const SizedBox(height: 16), - _buildTextField( - controller: TextEditingController(text: SpoofData.locale), - label: 'Локаль', - cs: cs, - readOnly: true, - ), - const SizedBox(height: 16), - _buildTextField( - controller: _deviceIdController, - label: 'ID устройства', - cs: cs, - ), - const SizedBox(height: 16), - _buildTextField( - controller: TextEditingController(text: SpoofData.appVersion), - label: 'Версия приложения', - cs: cs, - readOnly: true, - ), - const SizedBox(height: 16), - _buildTextField( - controller: TextEditingController(text: SpoofData.buildNumber), - label: 'Build Number', - cs: cs, - readOnly: true, - ), - const SizedBox(height: 16), - _buildTextField( - controller: _architectureController, - label: 'Архитектура', - cs: cs, - ), - const SizedBox(height: 32), - ], - ), - ), - ); - } - - Widget _buildTextField({ - required TextEditingController controller, - required String label, - required ColorScheme cs, - bool readOnly = false, - }) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: GoogleFonts.inter( - color: cs.onSurfaceVariant, - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - readOnly: readOnly, - style: GoogleFonts.inter( - color: readOnly ? cs.onSurfaceVariant : cs.onSurface, - fontSize: 15, - ), - decoration: InputDecoration( - filled: true, - fillColor: readOnly - ? cs.surfaceContainerHighest - : cs.surfaceContainerHigh, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - ), - ), - ], - ); - } -} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index ddaba8e..2389838 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import '../../../l10n/app_localizations.dart'; import '../auth/proxy_settings_sheet.dart'; import 'debug_menu_screen.dart'; import 'devices_screen.dart'; @@ -143,7 +144,7 @@ class _SettingsTabState extends State { ), _SettingsItem( icon: Symbols.shield_lock, - label: 'Подделка данных', + label: AppLocalizations.of(context)!.profileMenuSpoof, onTap: () { Navigator.push( context, diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index accabc4..b5ca254 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -1,9 +1,18 @@ +import 'dart:async'; +import 'dart:io'; import 'dart:math'; + +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../../core/config/spoof_data.dart'; -import '../../widgets/custom_notification.dart'; + +import '../../../core/config/device_presets.dart'; +import '../../../core/storage/spoofing_service.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart'; + +enum SpoofingMethod { partial, full } class SpoofScreen extends StatefulWidget { const SpoofScreen({super.key}); @@ -13,158 +22,400 @@ class SpoofScreen extends StatefulWidget { } class _SpoofScreenState extends State { - final _deviceTypeController = TextEditingController(); + static const String _hardcodedVersion = SpoofingService.hardcodedAppVersion; + static const int _hardcodedBuildNumber = + SpoofingService.hardcodedBuildNumber; + + final _random = Random(); final _deviceNameController = TextEditingController(); final _osVersionController = TextEditingController(); final _screenController = TextEditingController(); final _timezoneController = TextEditingController(); final _localeController = TextEditingController(); - final _deviceLocaleController = TextEditingController(); final _deviceIdController = TextEditingController(); final _appVersionController = TextEditingController(); final _buildNumberController = TextEditingController(); - final _architectureController = TextEditingController(); - final _pushDeviceTypeController = TextEditingController(); - final _mtInstanceIdController = TextEditingController(); - final _clientSessionIdController = TextEditingController(); + String _selectedDeviceType = 'ANDROID'; + String _selectedArch = 'arm64-v8a'; + SpoofingMethod _selectedMethod = SpoofingMethod.partial; bool _isLoading = true; @override void initState() { super.initState(); - _loadSettings(); + _loadInitialData(); } - Future _loadSettings() async { - final prefs = await SharedPreferences.getInstance(); - final random = Random(); + String _generateDeviceId() { + final bytes = List.generate(8, (_) => _random.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + Future _loadInitialData() async { + setState(() => _isLoading = true); + final prefs = await SharedPreferences.getInstance(); + final isSpoofingEnabled = prefs.getBool('spoofing_enabled') ?? false; + + if (isSpoofingEnabled) { + _deviceNameController.text = prefs.getString('spoof_devicename') ?? ''; + _osVersionController.text = prefs.getString('spoof_osversion') ?? ''; + _screenController.text = prefs.getString('spoof_screen') ?? ''; + _timezoneController.text = prefs.getString('spoof_timezone') ?? ''; + _localeController.text = prefs.getString('spoof_locale') ?? ''; + _deviceIdController.text = prefs.getString('spoof_deviceid') ?? ''; + _appVersionController.text = + prefs.getString('spoof_appversion') ?? _hardcodedVersion; + _selectedArch = prefs.getString('spoof_arch') ?? 'arm64-v8a'; + _buildNumberController.text = + prefs.getInt('spoof_buildnumber')?.toString() ?? + '$_hardcodedBuildNumber'; + + String savedType = prefs.getString('spoof_devicetype') ?? 'ANDROID'; + if (savedType == 'WEB') savedType = 'ANDROID'; + _selectedDeviceType = savedType; + if (mounted) setState(() => _isLoading = false); + } else { + await _loadDeviceData(); + } + } + + Future _loadDeviceData() async { + setState(() => _isLoading = true); + + final deviceInfo = DeviceInfoPlugin(); + final pixelRatio = View.of(context).devicePixelRatio; + final size = View.of(context).physicalSize; + + _appVersionController.text = _hardcodedVersion; + _localeController.text = Platform.localeName.split('_').first; + + final dpi = (160 * pixelRatio).round(); + String densityBucket; + if (dpi >= 560) { + densityBucket = 'xxxhdpi'; + } else if (dpi >= 380) { + densityBucket = 'xxhdpi'; + } else if (dpi >= 280) { + densityBucket = 'xhdpi'; + } else if (dpi >= 200) { + densityBucket = 'hdpi'; + } else if (dpi >= 140) { + densityBucket = 'mdpi'; + } else { + densityBucket = 'ldpi'; + } + _screenController.text = + '$densityBucket ${dpi}dpi ${size.width.round()}x${size.height.round()}'; + + final prefs = await SharedPreferences.getInstance(); + var realDeviceId = prefs.getString('real_device_id'); + if (realDeviceId == null || realDeviceId.isEmpty) { + realDeviceId = _generateDeviceId(); + await prefs.setString('real_device_id', realDeviceId); + } + _deviceIdController.text = realDeviceId; + + try { + final timezoneInfo = await FlutterTimezone.getLocalTimezone(); + _timezoneController.text = timezoneInfo.identifier; + } catch (_) { + _timezoneController.text = 'Europe/Moscow'; + } + + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + _deviceNameController.text = + '${androidInfo.manufacturer} ${androidInfo.model}'; + _osVersionController.text = 'Android ${androidInfo.version.release}'; + _selectedDeviceType = 'ANDROID'; + _selectedArch = androidInfo.supportedAbis.isNotEmpty + ? androidInfo.supportedAbis.first + : 'arm64-v8a'; + _buildNumberController.text = '$_hardcodedBuildNumber'; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + _deviceNameController.text = iosInfo.name; + _osVersionController.text = + '${iosInfo.systemName} ${iosInfo.systemVersion}'; + _selectedDeviceType = 'IOS'; + _selectedArch = 'arm64'; + _buildNumberController.text = '$_hardcodedBuildNumber'; + } else { + await _applyGeneratedData(); + } + + if (mounted) setState(() => _isLoading = false); + } + + Future _applyGeneratedData() async { + final filteredPresets = devicePresets + .where( + (p) => p.deviceType != 'WEB' && p.deviceType == _selectedDeviceType, + ) + .toList(); + + if (filteredPresets.isEmpty) return; + + final preset = filteredPresets[_random.nextInt(filteredPresets.length)]; + await _applyPreset(preset); + } + + Future _applyPreset(DevicePreset preset) async { setState(() { - _deviceTypeController.text = prefs.getString('spoof_device_type') ?? SpoofData.deviceType; - _deviceNameController.text = prefs.getString('spoof_device_name') ?? - SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; - _osVersionController.text = prefs.getString('spoof_os_version') ?? - 'Android ${SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]}'; - _screenController.text = prefs.getString('spoof_screen') ?? - SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; - _timezoneController.text = prefs.getString('spoof_timezone') ?? SpoofData.timezone; - _localeController.text = prefs.getString('spoof_locale') ?? SpoofData.locale; - _deviceLocaleController.text = prefs.getString('spoof_device_locale') ?? 'ru'; - _deviceIdController.text = prefs.getString('spoof_device_id') ?? - SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; - _appVersionController.text = prefs.getString('spoof_app_version') ?? SpoofData.appVersion; - _buildNumberController.text = prefs.getString('spoof_build_number') ?? SpoofData.buildNumber; - _architectureController.text = prefs.getString('spoof_architecture') ?? - SpoofData.architectures[random.nextInt(SpoofData.architectures.length)]; - _pushDeviceTypeController.text = prefs.getString('spoof_push_device_type') ?? 'GCM'; - _mtInstanceIdController.text = prefs.getString('spoof_mt_instanceid') ?? - '550e8400-e29b-41d4-a716-446655440000'; - _clientSessionIdController.text = prefs.getString('spoof_client_session_id') ?? '42'; - _isLoading = false; + _deviceNameController.text = preset.deviceName; + _osVersionController.text = preset.osVersion; + _screenController.text = preset.screen; + _appVersionController.text = _hardcodedVersion; + _deviceIdController.text = _generateDeviceId(); + + _selectedDeviceType = preset.deviceType; + + if (preset.deviceType == 'ANDROID') { + _selectedArch = 'arm64-v8a'; + } else if (preset.deviceType == 'IOS') { + _selectedArch = 'arm64'; + } else { + _selectedArch = 'x86_64'; + } + _buildNumberController.text = '$_hardcodedBuildNumber'; + + if (_selectedMethod == SpoofingMethod.full) { + _timezoneController.text = preset.timezone; + _localeController.text = preset.locale; + } }); + + if (_selectedMethod == SpoofingMethod.partial) { + String timezone; + try { + final timezoneInfo = await FlutterTimezone.getLocalTimezone(); + timezone = timezoneInfo.identifier; + } catch (_) { + timezone = 'Europe/Moscow'; + } + final locale = Platform.localeName.split('_').first; + + if (mounted) { + setState(() { + _timezoneController.text = timezone; + _localeController.text = locale; + }); + } + } } - Future _saveSettings() async { + Future _saveSpoofingSettings() async { + if (!mounted) return; + final prefs = await SharedPreferences.getInstance(); - await prefs.setString('spoof_device_type', _deviceTypeController.text); - await prefs.setString('spoof_device_name', _deviceNameController.text); - await prefs.setString('spoof_os_version', _osVersionController.text); + + final oldValues = { + 'device_name': prefs.getString('spoof_devicename') ?? '', + 'os_version': prefs.getString('spoof_osversion') ?? '', + 'screen': prefs.getString('spoof_screen') ?? '', + 'timezone': prefs.getString('spoof_timezone') ?? '', + 'locale': prefs.getString('spoof_locale') ?? '', + 'device_id': prefs.getString('spoof_deviceid') ?? '', + 'device_type': prefs.getString('spoof_devicetype') ?? 'ANDROID', + 'arch': prefs.getString('spoof_arch') ?? '', + 'build_number': prefs.getInt('spoof_buildnumber')?.toString() ?? '', + }; + + final newValues = { + 'device_name': _deviceNameController.text, + 'os_version': _osVersionController.text, + 'screen': _screenController.text, + 'timezone': _timezoneController.text, + 'locale': _localeController.text, + 'device_id': _deviceIdController.text, + 'device_type': _selectedDeviceType, + 'arch': _selectedArch, + 'build_number': _buildNumberController.text, + }; + + final oldAppVersion = + prefs.getString('spoof_appversion') ?? _hardcodedVersion; + final newAppVersion = _appVersionController.text; + + bool otherDataChanged = false; + for (final key in oldValues.keys) { + if (oldValues[key] != newValues[key]) { + otherDataChanged = true; + break; + } + } + + final appVersionChanged = oldAppVersion != newAppVersion; + final isChangingAwayFromHardcoded = newAppVersion != _hardcodedVersion; + + if (appVersionChanged && isChangingAwayFromHardcoded) { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.spoofDialogUnsureTitle), + content: Text(l10n.spoofDialogUnsureContent), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.spoofDialogCancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.spoofDialogYes), + ), + ], + ), + ); + if (confirmed != true) return; + } + + if (appVersionChanged && !otherDataChanged) { + await _saveAllData(prefs); + if (mounted) Navigator.of(context).pop(); + return; + } + + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.spoofDialogApplyTitle), + content: Text(l10n.spoofDialogApplyContent), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(l10n.spoofDialogApplyDeny), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(l10n.spoofDialogApplyConfirm), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + await _saveAllData(prefs); + + try { + await api.disconnect(); + await api.connect(); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.spoofErrorApplyFailed(e.toString()), + ), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + Future _saveAllData(SharedPreferences prefs) async { + await prefs.setBool('spoofing_enabled', true); + await prefs.setString('spoof_devicename', _deviceNameController.text); + await prefs.setString('spoof_osversion', _osVersionController.text); await prefs.setString('spoof_screen', _screenController.text); await prefs.setString('spoof_timezone', _timezoneController.text); await prefs.setString('spoof_locale', _localeController.text); - await prefs.setString('spoof_device_locale', _deviceLocaleController.text); - await prefs.setString('spoof_device_id', _deviceIdController.text); - await prefs.setString('spoof_app_version', _appVersionController.text); - await prefs.setString('spoof_build_number', _buildNumberController.text); - await prefs.setString('spoof_architecture', _architectureController.text); - await prefs.setString('spoof_push_device_type', _pushDeviceTypeController.text); - await prefs.setString('spoof_mt_instanceid', _mtInstanceIdController.text); - await prefs.setString('spoof_client_session_id', _clientSessionIdController.text); - - if (mounted) { - showCustomNotification(context, 'Настройки сохранены'); - } + await prefs.setString('spoof_deviceid', _deviceIdController.text); + await prefs.setString('spoof_devicetype', _selectedDeviceType); + await prefs.setString('spoof_appversion', _appVersionController.text); + await prefs.setString('spoof_arch', _selectedArch); + await prefs.setInt( + 'spoof_buildnumber', + int.tryParse(_buildNumberController.text) ?? _hardcodedBuildNumber, + ); } - Future _randomizeAll() async { - final random = Random(); + void _generateNewDeviceId() { setState(() { - _deviceNameController.text = SpoofData.deviceNames[random.nextInt(SpoofData.deviceNames.length)]; - _osVersionController.text = 'Android ${SpoofData.osVersions[random.nextInt(SpoofData.osVersions.length)]}'; - _screenController.text = SpoofData.resolutions[random.nextInt(SpoofData.resolutions.length)]; - _deviceIdController.text = SpoofData.deviceIds[random.nextInt(SpoofData.deviceIds.length)]; - _architectureController.text = SpoofData.architectures[random.nextInt(SpoofData.architectures.length)]; + _deviceIdController.text = _generateDeviceId(); }); - await _saveSettings(); - if (mounted) { - showCustomNotification(context, 'Данные рандомизированы'); - } } @override void dispose() { - _deviceTypeController.dispose(); _deviceNameController.dispose(); _osVersionController.dispose(); _screenController.dispose(); _timezoneController.dispose(); _localeController.dispose(); - _deviceLocaleController.dispose(); _deviceIdController.dispose(); _appVersionController.dispose(); _buildNumberController.dispose(); - _architectureController.dispose(); - _pushDeviceTypeController.dispose(); - _mtInstanceIdController.dispose(); - _clientSessionIdController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - - if (_isLoading) { - return Scaffold( - backgroundColor: cs.surface, - body: const Center(child: CircularProgressIndicator()), - ); - } - + final l10n = AppLocalizations.of(context)!; return Scaffold( - backgroundColor: cs.surface, - body: SafeArea( - bottom: false, - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverToBoxAdapter(child: _buildAppBar(context, cs)), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _buildSection(cs, [ - _buildField(cs, 'Тип устройства', _deviceTypeController), - _buildField(cs, 'Имя устройства', _deviceNameController), - _buildField(cs, 'Версия ОС', _osVersionController), - _buildField(cs, 'Разрешение экрана', _screenController), - _buildField(cs, 'Архитектура', _architectureController), - _buildField(cs, 'ID устройства', _deviceIdController), - _buildField(cs, 'Часовой пояс', _timezoneController), - _buildField(cs, 'Локаль', _localeController), - _buildField(cs, 'Локаль устройства', _deviceLocaleController), - _buildField(cs, 'Версия приложения', _appVersionController), - _buildField(cs, 'Build Number', _buildNumberController), - _buildField(cs, 'Push Device Type', _pushDeviceTypeController), - _buildField(cs, 'MT Instance ID', _mtInstanceIdController), - _buildField(cs, 'Client Session ID', _clientSessionIdController, isLast: true), - ]), + appBar: AppBar( + title: Text(l10n.spoofScreenTitle), + centerTitle: true, + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildInfoCard(), + const SizedBox(height: 16), + _buildSpoofingMethodCard(), + const SizedBox(height: 16), + _buildDeviceTypeCard(), + const SizedBox(height: 24), + _buildMainDataCard(), + const SizedBox(height: 16), + _buildRegionalDataCard(), + const SizedBox(height: 16), + _buildIdentifiersCard(), + ], ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), - child: _buildActionButtons(cs), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: _buildFloatingActionButtons(), + ); + } + + Widget _buildInfoCard() { + final l10n = AppLocalizations.of(context)!; + return Card( + color: Theme.of( + context, + ).colorScheme.secondaryContainer.withValues(alpha: 0.5), + elevation: 0, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.touch_app, + size: 18, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + l10n.spoofInfoHint, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), ), ), ], @@ -173,161 +424,388 @@ class _SpoofScreenState extends State { ); } - Widget _buildAppBar(BuildContext context, ColorScheme cs) { + Widget _buildSpoofingMethodCard() { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + Widget descriptionWidget; + + if (_selectedMethod == SpoofingMethod.partial) { + descriptionWidget = _buildDescriptionTile( + icon: Icons.check_circle_outline, + color: Colors.green.shade700, + text: l10n.spoofMethodPartialDescription, + ); + } else { + descriptionWidget = _buildDescriptionTile( + icon: Icons.warning_amber_rounded, + color: theme.colorScheme.error, + text: l10n.spoofMethodFullDescription, + ); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Text(l10n.spoofMethodTitle, style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + SegmentedButton( + style: SegmentedButton.styleFrom(shape: const StadiumBorder()), + segments: [ + ButtonSegment( + value: SpoofingMethod.partial, + label: Text(l10n.spoofMethodPartial), + icon: const Icon(Icons.security_outlined), + ), + ButtonSegment( + value: SpoofingMethod.full, + label: Text(l10n.spoofMethodFull), + icon: const Icon(Icons.public_outlined), + ), + ], + selected: {_selectedMethod}, + onSelectionChanged: (s) => + setState(() => _selectedMethod = s.first), + ), + const SizedBox(height: 12), + descriptionWidget, + ], + ), + ), + ); + } + + Widget _buildDeviceTypeCard() { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.spoofDeviceTypeTitle, style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + _buildDescriptionTile( + icon: Icons.info_outline, + color: theme.colorScheme.primary, + text: l10n.spoofDeviceTypeDescription, + ), + const SizedBox(height: 12), + _buildChipSelector( + options: const [ + _ChipOption('ANDROID', 'ANDROID', Icons.android_outlined), + _ChipOption('IOS', 'iOS', Icons.phone_iphone_outlined), + _ChipOption( + 'DESKTOP', + 'Desktop', + Icons.desktop_windows_outlined, + ), + ], + selected: _selectedDeviceType, + onSelected: (value) { + setState(() { + _selectedDeviceType = value; + if (value == 'ANDROID') { + _selectedArch = 'arm64-v8a'; + } else if (value == 'IOS') { + _selectedArch = 'arm64'; + } else { + _selectedArch = 'x86_64'; + } + }); + }, + ), + ], + ), + ), + ); + } + + Widget _buildDescriptionTile({ + required IconData icon, + required Color color, + required String text, + }) { + return ListTile( + leading: Icon(icon, color: color), + contentPadding: EdgeInsets.zero, + title: Text( + text, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + padding: const EdgeInsets.only(bottom: 16.0, top: 8.0), + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildMainDataCard() { + final l10n = AppLocalizations.of(context)!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, l10n.spoofMainSectionTitle), + TextField( + controller: _deviceNameController, + decoration: _inputDecoration( + l10n.spoofFieldDeviceName, + Icons.smartphone_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _osVersionController, + decoration: _inputDecoration( + l10n.spoofFieldOsVersion, + Icons.layers_outlined, + ), + ), + ], + ), + ), + ); + } + + Widget _buildRegionalDataCard() { + final l10n = AppLocalizations.of(context)!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, l10n.spoofRegionalSectionTitle), + TextField( + controller: _screenController, + decoration: _inputDecoration( + l10n.spoofFieldScreen, + Icons.fullscreen_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _timezoneController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldTimezone, + Icons.public_outlined, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _localeController, + enabled: _selectedMethod == SpoofingMethod.full, + decoration: _inputDecoration( + l10n.spoofFieldLocale, + Icons.language_outlined, + ), + ), + ], + ), + ), + ); + } + + Widget _buildIdentifiersCard() { + final l10n = AppLocalizations.of(context)!; + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(context, l10n.spoofIdentifiersSectionTitle), + _buildDescriptionTile( + icon: Icons.info_outline, + color: Theme.of(context).colorScheme.tertiary, + text: l10n.spoofIdentifiersDescription, + ), + const SizedBox(height: 12), + TextField( + controller: _deviceIdController, + decoration: + _inputDecoration(l10n.spoofFieldDeviceId, Icons.tag_outlined) + .copyWith( + suffixIcon: IconButton( + icon: const Icon(Icons.autorenew_outlined), + tooltip: l10n.spoofRegenerateIdTooltip, + onPressed: _generateNewDeviceId, + ), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _appVersionController, + decoration: _inputDecoration( + l10n.spoofFieldAppVersion, + Icons.info_outline_rounded, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _buildNumberController, + keyboardType: TextInputType.number, + decoration: _inputDecoration( + l10n.spoofFieldBuildNumber, + Icons.numbers_outlined, + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + l10n.spoofFieldArchitecture, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + _buildChipSelector( + options: const [ + _ChipOption('arm64-v8a', 'arm64-v8a', Icons.memory_outlined), + _ChipOption( + 'armeabi-v7a', + 'armeabi-v7a', + Icons.memory_outlined, + ), + _ChipOption('x86', 'x86', Icons.memory_outlined), + _ChipOption('x86_64', 'x86_64', Icons.memory_outlined), + _ChipOption('arm64', 'arm64', Icons.memory_outlined), + ], + selected: _selectedArch, + onSelected: (value) => + setState(() => _selectedArch = value), + ), + ], + ), + ), + ); + } + + InputDecoration _inputDecoration(String label, IconData icon) { + return InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, + ); + } + + Widget _buildChipSelector({ + required List<_ChipOption> options, + required T selected, + required ValueChanged onSelected, + }) { + final cs = Theme.of(context).colorScheme; + return Wrap( + spacing: 8, + runSpacing: 8, + children: options.map((opt) { + final isSelected = opt.value == selected; + return ChoiceChip( + label: Text(opt.label), + avatar: isSelected + ? Icon(Icons.check, size: 18, color: cs.onSecondaryContainer) + : (opt.icon != null + ? Icon(opt.icon, size: 18, color: cs.onSurfaceVariant) + : null), + selected: isSelected, + showCheckmark: false, + onSelected: (_) => onSelected(opt.value), + labelStyle: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: isSelected ? cs.onSecondaryContainer : cs.onSurface, + ), + backgroundColor: cs.surfaceContainerHighest, + selectedColor: cs.secondaryContainer, + side: BorderSide( + color: isSelected ? Colors.transparent : cs.outlineVariant, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + }).toList(), + ); + } + + Widget _buildFloatingActionButtons() { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Row( children: [ - IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface, size: 24, weight: 400), - onPressed: () => Navigator.pop(context), + Expanded( + flex: 1, + child: FilledButton.tonal( + onPressed: _applyGeneratedData, + onLongPress: _loadDeviceData, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + shape: const StadiumBorder(), + ), + child: Text(l10n.spoofButtonGenerate), + ), ), - const SizedBox(width: 4), - Text( - 'Подделка данных', - style: TextStyle( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w700, - fontFamily: 'Outfit', + const SizedBox(width: 12), + Expanded( + flex: 1, + child: FilledButton( + onPressed: _saveSpoofingSettings, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + shape: const StadiumBorder(), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.save_alt_outlined), + const SizedBox(width: 8), + Text(l10n.spoofButtonApply), + ], + ), ), ), ], ), ); } +} +class _ChipOption { + final T value; + final String label; + final IconData? icon; - - Widget _buildSection(ColorScheme cs, List children) { - return Container( - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - ), - child: Column(children: children), - ); - } - - Widget _buildField( - ColorScheme cs, - String label, - TextEditingController controller, { - bool isLast = false, - }) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 140, - child: Text( - label, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: controller, - style: TextStyle( - color: cs.onSurface, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - decoration: InputDecoration( - filled: true, - fillColor: cs.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - isDense: true, - ), - ), - ), - ], - ), - ), - if (!isLast) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Divider( - height: 1, - thickness: 1, - color: cs.outlineVariant.withValues(alpha: 0.35), - ), - ), - ], - ); - } - - Widget _buildActionButtons(ColorScheme cs) { - return Row( - children: [ - Expanded( - child: SizedBox( - height: 48, - child: OutlinedButton( - onPressed: _randomizeAll, - style: OutlinedButton.styleFrom( - foregroundColor: cs.onSurface, - side: BorderSide(color: cs.outline, width: 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Symbols.shuffle, size: 18, weight: 400), - const SizedBox(width: 6), - const Text( - 'Рандомизировать', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: SizedBox( - height: 48, - child: FilledButton( - onPressed: _saveSettings, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - child: const Text( - 'Сохранить', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ], - ); - } + const _ChipOption(this.value, this.label, [this.icon]); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ae9b610..c047aaa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12,7 +12,7 @@ "loginEdit": "Change", "loginDone": "Done", "loginReadTermsNotification": "Please read the terms of use first", - "loginSpoofRedacted": "Spoof redaction", + "loginSpoofRedacted": "Spoofing", "loginProxy": "Proxy", "loginChangeServer": "Change server", "serverSettingsTitle": "Server", @@ -54,5 +54,49 @@ "proxyApply": "Apply and reconnect", "proxyDisable": "Disable proxy", "proxySettingsSaved": "Proxy settings applied", - "proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)" + "proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)", + + "spoofScreenTitle": "Session spoofing", + "spoofInfoHint": "Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.", + "spoofMethodTitle": "Spoofing method", + "spoofMethodPartial": "Partial", + "spoofMethodFull": "Full", + "spoofMethodPartialDescription": "Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.", + "spoofMethodFullDescription": "All data including timezone and locale is generated randomly. Use this method at your own risk!", + "spoofDeviceTypeTitle": "Device type", + "spoofDeviceTypeDescription": "Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.", + "spoofDeviceTypeLabel": "Device type", + "spoofMainSectionTitle": "Main data", + "spoofFieldDeviceName": "Device name", + "spoofFieldOsVersion": "OS version", + "spoofRegionalSectionTitle": "Regional data", + "spoofFieldScreen": "Screen resolution", + "spoofFieldTimezone": "Timezone", + "spoofFieldLocale": "Locale", + "spoofIdentifiersSectionTitle": "Identifiers", + "spoofIdentifiersDescription": "mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.", + "spoofFieldDeviceId": "Device ID", + "spoofRegenerateIdTooltip": "Generate a new ID", + "spoofFieldAppVersion": "App version", + "spoofFieldBuildNumber": "Build number", + "spoofFieldArchitecture": "Architecture", + "spoofButtonGenerate": "Generate", + "spoofButtonApply": "Apply", + "spoofDialogUnsureTitle": "Are you sure?", + "spoofDialogUnsureContent": "The app may become unstable due to API incompatibility", + "spoofDialogCancel": "Cancel", + "spoofDialogYes": "Yes", + "spoofDialogApplyTitle": "Apply settings?", + "spoofDialogApplyContent": "Need to reconnect the app, ok?", + "spoofDialogApplyDeny": "No", + "spoofDialogApplyConfirm": "Ok!", + "spoofErrorApplyFailed": "Failed to apply settings: {error}", + "@spoofErrorApplyFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "profileMenuSpoof": "Spoofing" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6741f70..15fbdb0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -62,7 +62,8 @@ import 'app_localizations_ru.dart'; /// be consistent with the languages listed in the AppLocalizations.supportedLocales /// property. abstract class AppLocalizations { - AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -70,7 +71,8 @@ abstract class AppLocalizations { return Localizations.of(context, AppLocalizations); } - static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); /// A list of this localizations delegate along with the default localizations /// delegates. @@ -82,17 +84,18 @@ abstract class AppLocalizations { /// Additional delegates can be added by appending to this list in /// MaterialApp. This list does not have to be used at all if a custom list /// of delegates is preferred or required. - static const List> localizationsDelegates = >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ Locale('en'), - Locale('ru') + Locale('ru'), ]; /// No description provided for @loginTitle. @@ -170,7 +173,7 @@ abstract class AppLocalizations { /// No description provided for @loginSpoofRedacted. /// /// In en, this message translates to: - /// **'Spoof redaction'** + /// **'Spoofing'** String get loginSpoofRedacted; /// No description provided for @loginProxy. @@ -376,9 +379,226 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Enter a valid proxy host and port (1–65535)'** String get proxyInvalidHostOrPort; + + /// No description provided for @spoofScreenTitle. + /// + /// In en, this message translates to: + /// **'Session spoofing'** + String get spoofScreenTitle; + + /// No description provided for @spoofInfoHint. + /// + /// In en, this message translates to: + /// **'Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.'** + String get spoofInfoHint; + + /// No description provided for @spoofMethodTitle. + /// + /// In en, this message translates to: + /// **'Spoofing method'** + String get spoofMethodTitle; + + /// No description provided for @spoofMethodPartial. + /// + /// In en, this message translates to: + /// **'Partial'** + String get spoofMethodPartial; + + /// No description provided for @spoofMethodFull. + /// + /// In en, this message translates to: + /// **'Full'** + String get spoofMethodFull; + + /// No description provided for @spoofMethodPartialDescription. + /// + /// In en, this message translates to: + /// **'Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.'** + String get spoofMethodPartialDescription; + + /// No description provided for @spoofMethodFullDescription. + /// + /// In en, this message translates to: + /// **'All data including timezone and locale is generated randomly. Use this method at your own risk!'** + String get spoofMethodFullDescription; + + /// No description provided for @spoofDeviceTypeTitle. + /// + /// In en, this message translates to: + /// **'Device type'** + String get spoofDeviceTypeTitle; + + /// No description provided for @spoofDeviceTypeDescription. + /// + /// In en, this message translates to: + /// **'Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.'** + String get spoofDeviceTypeDescription; + + /// No description provided for @spoofDeviceTypeLabel. + /// + /// In en, this message translates to: + /// **'Device type'** + String get spoofDeviceTypeLabel; + + /// No description provided for @spoofMainSectionTitle. + /// + /// In en, this message translates to: + /// **'Main data'** + String get spoofMainSectionTitle; + + /// No description provided for @spoofFieldDeviceName. + /// + /// In en, this message translates to: + /// **'Device name'** + String get spoofFieldDeviceName; + + /// No description provided for @spoofFieldOsVersion. + /// + /// In en, this message translates to: + /// **'OS version'** + String get spoofFieldOsVersion; + + /// No description provided for @spoofRegionalSectionTitle. + /// + /// In en, this message translates to: + /// **'Regional data'** + String get spoofRegionalSectionTitle; + + /// No description provided for @spoofFieldScreen. + /// + /// In en, this message translates to: + /// **'Screen resolution'** + String get spoofFieldScreen; + + /// No description provided for @spoofFieldTimezone. + /// + /// In en, this message translates to: + /// **'Timezone'** + String get spoofFieldTimezone; + + /// No description provided for @spoofFieldLocale. + /// + /// In en, this message translates to: + /// **'Locale'** + String get spoofFieldLocale; + + /// No description provided for @spoofIdentifiersSectionTitle. + /// + /// In en, this message translates to: + /// **'Identifiers'** + String get spoofIdentifiersSectionTitle; + + /// No description provided for @spoofIdentifiersDescription. + /// + /// In en, this message translates to: + /// **'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'** + String get spoofIdentifiersDescription; + + /// No description provided for @spoofFieldDeviceId. + /// + /// In en, this message translates to: + /// **'Device ID'** + String get spoofFieldDeviceId; + + /// No description provided for @spoofRegenerateIdTooltip. + /// + /// In en, this message translates to: + /// **'Generate a new ID'** + String get spoofRegenerateIdTooltip; + + /// No description provided for @spoofFieldAppVersion. + /// + /// In en, this message translates to: + /// **'App version'** + String get spoofFieldAppVersion; + + /// No description provided for @spoofFieldBuildNumber. + /// + /// In en, this message translates to: + /// **'Build number'** + String get spoofFieldBuildNumber; + + /// No description provided for @spoofFieldArchitecture. + /// + /// In en, this message translates to: + /// **'Architecture'** + String get spoofFieldArchitecture; + + /// No description provided for @spoofButtonGenerate. + /// + /// In en, this message translates to: + /// **'Generate'** + String get spoofButtonGenerate; + + /// No description provided for @spoofButtonApply. + /// + /// In en, this message translates to: + /// **'Apply'** + String get spoofButtonApply; + + /// No description provided for @spoofDialogUnsureTitle. + /// + /// In en, this message translates to: + /// **'Are you sure?'** + String get spoofDialogUnsureTitle; + + /// No description provided for @spoofDialogUnsureContent. + /// + /// In en, this message translates to: + /// **'The app may become unstable due to API incompatibility'** + String get spoofDialogUnsureContent; + + /// No description provided for @spoofDialogCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get spoofDialogCancel; + + /// No description provided for @spoofDialogYes. + /// + /// In en, this message translates to: + /// **'Yes'** + String get spoofDialogYes; + + /// No description provided for @spoofDialogApplyTitle. + /// + /// In en, this message translates to: + /// **'Apply settings?'** + String get spoofDialogApplyTitle; + + /// No description provided for @spoofDialogApplyContent. + /// + /// In en, this message translates to: + /// **'Need to reconnect the app, ok?'** + String get spoofDialogApplyContent; + + /// No description provided for @spoofDialogApplyDeny. + /// + /// In en, this message translates to: + /// **'No'** + String get spoofDialogApplyDeny; + + /// No description provided for @spoofDialogApplyConfirm. + /// + /// In en, this message translates to: + /// **'Ok!'** + String get spoofDialogApplyConfirm; + + /// No description provided for @spoofErrorApplyFailed. + /// + /// In en, this message translates to: + /// **'Failed to apply settings: {error}'** + String spoofErrorApplyFailed(String error); + + /// No description provided for @profileMenuSpoof. + /// + /// In en, this message translates to: + /// **'Spoofing'** + String get profileMenuSpoof; } -class _AppLocalizationsDelegate extends LocalizationsDelegate { +class _AppLocalizationsDelegate + extends LocalizationsDelegate { const _AppLocalizationsDelegate(); @override @@ -387,25 +607,26 @@ class _AppLocalizationsDelegate extends LocalizationsDelegate } @override - bool isSupported(Locale locale) => ['en', 'ru'].contains(locale.languageCode); + bool isSupported(Locale locale) => + ['en', 'ru'].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; } AppLocalizations lookupAppLocalizations(Locale locale) { - - // Lookup logic when only language code is specified. switch (locale.languageCode) { - case 'en': return AppLocalizationsEn(); - case 'ru': return AppLocalizationsRu(); + case 'en': + return AppLocalizationsEn(); + case 'ru': + return AppLocalizationsRu(); } throw FlutterError( 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' 'an issue with the localizations generation tool. Please file an issue ' 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.' + 'that was used.', ); } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 68d2d21..4422b8f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -12,7 +12,8 @@ class AppLocalizationsEn extends AppLocalizations { String get loginTitle => 'Sign in to Komet'; @override - String get loginSubtitle => 'Check your country code and enter your\nphone number.'; + String get loginSubtitle => + 'Check your country code and enter your\nphone number.'; @override String get loginCountry => 'Country'; @@ -45,7 +46,7 @@ class AppLocalizationsEn extends AppLocalizations { String get loginReadTermsNotification => 'Please read the terms of use first'; @override - String get loginSpoofRedacted => 'Spoof redaction'; + String get loginSpoofRedacted => 'Spoofing'; @override String get loginProxy => 'Proxy'; @@ -102,7 +103,8 @@ class AppLocalizationsEn extends AppLocalizations { String get selectCountrySearchHint => 'Search countries…'; @override - String get codeConfirmationSmsSent => 'We sent an SMS with a verification code to your phone number.'; + String get codeConfirmationSmsSent => + 'We sent an SMS with a verification code to your phone number.'; @override String codeResendInSeconds(int seconds) { @@ -149,5 +151,122 @@ class AppLocalizationsEn extends AppLocalizations { String get proxySettingsSaved => 'Proxy settings applied'; @override - String get proxyInvalidHostOrPort => 'Enter a valid proxy host and port (1–65535)'; + String get proxyInvalidHostOrPort => + 'Enter a valid proxy host and port (1–65535)'; + + @override + String get spoofScreenTitle => 'Session spoofing'; + + @override + String get spoofInfoHint => + 'Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.'; + + @override + String get spoofMethodTitle => 'Spoofing method'; + + @override + String get spoofMethodPartial => 'Partial'; + + @override + String get spoofMethodFull => 'Full'; + + @override + String get spoofMethodPartialDescription => + 'Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.'; + + @override + String get spoofMethodFullDescription => + 'All data including timezone and locale is generated randomly. Use this method at your own risk!'; + + @override + String get spoofDeviceTypeTitle => 'Device type'; + + @override + String get spoofDeviceTypeDescription => + 'Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.'; + + @override + String get spoofDeviceTypeLabel => 'Device type'; + + @override + String get spoofMainSectionTitle => 'Main data'; + + @override + String get spoofFieldDeviceName => 'Device name'; + + @override + String get spoofFieldOsVersion => 'OS version'; + + @override + String get spoofRegionalSectionTitle => 'Regional data'; + + @override + String get spoofFieldScreen => 'Screen resolution'; + + @override + String get spoofFieldTimezone => 'Timezone'; + + @override + String get spoofFieldLocale => 'Locale'; + + @override + String get spoofIdentifiersSectionTitle => 'Identifiers'; + + @override + String get spoofIdentifiersDescription => + 'mt_instanceid and clientSessionId are generated automatically on every app launch. Only the Device ID can be changed.'; + + @override + String get spoofFieldDeviceId => 'Device ID'; + + @override + String get spoofRegenerateIdTooltip => 'Generate a new ID'; + + @override + String get spoofFieldAppVersion => 'App version'; + + @override + String get spoofFieldBuildNumber => 'Build number'; + + @override + String get spoofFieldArchitecture => 'Architecture'; + + @override + String get spoofButtonGenerate => 'Generate'; + + @override + String get spoofButtonApply => 'Apply'; + + @override + String get spoofDialogUnsureTitle => 'Are you sure?'; + + @override + String get spoofDialogUnsureContent => + 'The app may become unstable due to API incompatibility'; + + @override + String get spoofDialogCancel => 'Cancel'; + + @override + String get spoofDialogYes => 'Yes'; + + @override + String get spoofDialogApplyTitle => 'Apply settings?'; + + @override + String get spoofDialogApplyContent => 'Need to reconnect the app, ok?'; + + @override + String get spoofDialogApplyDeny => 'No'; + + @override + String get spoofDialogApplyConfirm => 'Ok!'; + + @override + String spoofErrorApplyFailed(String error) { + return 'Failed to apply settings: $error'; + } + + @override + String get profileMenuSpoof => 'Spoofing'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7f58d45..6648921 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -12,7 +12,8 @@ class AppLocalizationsRu extends AppLocalizations { String get loginTitle => 'Войдите в Komet'; @override - String get loginSubtitle => 'Проверьте код страны и введите свой\nномер телефона.'; + String get loginSubtitle => + 'Проверьте код страны и введите свой\nномер телефона.'; @override String get loginCountry => 'Страна'; @@ -42,10 +43,11 @@ class AppLocalizationsRu extends AppLocalizations { String get loginDone => 'Готово'; @override - String get loginReadTermsNotification => 'Сначала прочитайте условия использования'; + String get loginReadTermsNotification => + 'Сначала прочитайте условия использования'; @override - String get loginSpoofRedacted => 'Подделка спуфа'; + String get loginSpoofRedacted => 'Подмена данных'; @override String get loginProxy => 'Прокси'; @@ -69,7 +71,8 @@ class AppLocalizationsRu extends AppLocalizations { String get serverUseDefault => 'Сбросить к умолчанию'; @override - String get serverInvalidHostOrPort => 'Укажите корректный хост и порт (1–65535)'; + String get serverInvalidHostOrPort => + 'Укажите корректный хост и порт (1–65535)'; @override String get serverSettingsSaved => 'Настройки сервера применены'; @@ -102,7 +105,8 @@ class AppLocalizationsRu extends AppLocalizations { String get selectCountrySearchHint => 'Поиск страны…'; @override - String get codeConfirmationSmsSent => 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.'; + String get codeConfirmationSmsSent => + 'Мы отправили SMS с кодом подтверждения на ваш номер телефона.'; @override String codeResendInSeconds(int seconds) { @@ -149,5 +153,122 @@ class AppLocalizationsRu extends AppLocalizations { String get proxySettingsSaved => 'Настройки прокси применены'; @override - String get proxyInvalidHostOrPort => 'Укажите корректный хост и порт прокси (1–65535)'; + String get proxyInvalidHostOrPort => + 'Укажите корректный хост и порт прокси (1–65535)'; + + @override + String get spoofScreenTitle => 'Подмена данных сессии'; + + @override + String get spoofInfoHint => + 'Нажмите \"Сгенерировать\":\n• Короткое нажатие: случайный пресет.\n• Длинное нажатие: реальные данные.'; + + @override + String get spoofMethodTitle => 'Метод подмены'; + + @override + String get spoofMethodPartial => 'Частичный'; + + @override + String get spoofMethodFull => 'Полный'; + + @override + String get spoofMethodPartialDescription => + 'Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.'; + + @override + String get spoofMethodFullDescription => + 'Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!'; + + @override + String get spoofDeviceTypeTitle => 'Тип устройства'; + + @override + String get spoofDeviceTypeDescription => + 'Выберите тип устройства для генерации пресетов. При нажатии \"Сгенерировать\" будут использоваться только пресеты выбранного типа.'; + + @override + String get spoofDeviceTypeLabel => 'Тип устройства'; + + @override + String get spoofMainSectionTitle => 'Основные данные'; + + @override + String get spoofFieldDeviceName => 'Имя устройства'; + + @override + String get spoofFieldOsVersion => 'Версия ОС'; + + @override + String get spoofRegionalSectionTitle => 'Региональные данные'; + + @override + String get spoofFieldScreen => 'Разрешение экрана'; + + @override + String get spoofFieldTimezone => 'Часовой пояс'; + + @override + String get spoofFieldLocale => 'Локаль'; + + @override + String get spoofIdentifiersSectionTitle => 'Идентификаторы'; + + @override + String get spoofIdentifiersDescription => + 'mt_instanceid и clientSessionId генерируются автоматически при каждом запуске приложения. Изменить можно только Device ID.'; + + @override + String get spoofFieldDeviceId => 'ID Устройства'; + + @override + String get spoofRegenerateIdTooltip => 'Сгенерировать новый ID'; + + @override + String get spoofFieldAppVersion => 'Версия приложения'; + + @override + String get spoofFieldBuildNumber => 'Build Number'; + + @override + String get spoofFieldArchitecture => 'Архитектура'; + + @override + String get spoofButtonGenerate => 'Сгенерировать'; + + @override + String get spoofButtonApply => 'Применить'; + + @override + String get spoofDialogUnsureTitle => 'Ты уверен?'; + + @override + String get spoofDialogUnsureContent => + 'Приложение может начать работать нестабильно из-за несовместимости API'; + + @override + String get spoofDialogCancel => 'Отмена'; + + @override + String get spoofDialogYes => 'Да'; + + @override + String get spoofDialogApplyTitle => 'Применить настройки?'; + + @override + String get spoofDialogApplyContent => 'Нужно перезайти в приложение, ок?'; + + @override + String get spoofDialogApplyDeny => 'Не'; + + @override + String get spoofDialogApplyConfirm => 'Ок!'; + + @override + String spoofErrorApplyFailed(String error) { + return 'Ошибка при применении настроек: $error'; + } + + @override + String get profileMenuSpoof => 'Подмена данных'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index babcc9e..2efbabb 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -12,7 +12,7 @@ "loginEdit": "Изменить", "loginDone": "Готово", "loginReadTermsNotification": "Сначала прочитайте условия использования", - "loginSpoofRedacted": "Подделка спуфа", + "loginSpoofRedacted": "Подмена данных", "loginProxy": "Прокси", "loginChangeServer": "Смена сервера", "serverSettingsTitle": "Сервер", @@ -54,5 +54,49 @@ "proxyApply": "Применить и переподключиться", "proxyDisable": "Отключить прокси", "proxySettingsSaved": "Настройки прокси применены", - "proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)" + "proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)", + + "spoofScreenTitle": "Подмена данных сессии", + "spoofInfoHint": "Нажмите \"Сгенерировать\":\n• Короткое нажатие: случайный пресет.\n• Длинное нажатие: реальные данные.", + "spoofMethodTitle": "Метод подмены", + "spoofMethodPartial": "Частичный", + "spoofMethodFull": "Полный", + "spoofMethodPartialDescription": "Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.", + "spoofMethodFullDescription": "Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!", + "spoofDeviceTypeTitle": "Тип устройства", + "spoofDeviceTypeDescription": "Выберите тип устройства для генерации пресетов. При нажатии \"Сгенерировать\" будут использоваться только пресеты выбранного типа.", + "spoofDeviceTypeLabel": "Тип устройства", + "spoofMainSectionTitle": "Основные данные", + "spoofFieldDeviceName": "Имя устройства", + "spoofFieldOsVersion": "Версия ОС", + "spoofRegionalSectionTitle": "Региональные данные", + "spoofFieldScreen": "Разрешение экрана", + "spoofFieldTimezone": "Часовой пояс", + "spoofFieldLocale": "Локаль", + "spoofIdentifiersSectionTitle": "Идентификаторы", + "spoofIdentifiersDescription": "mt_instanceid и clientSessionId генерируются автоматически при каждом запуске приложения. Изменить можно только Device ID.", + "spoofFieldDeviceId": "ID Устройства", + "spoofRegenerateIdTooltip": "Сгенерировать новый ID", + "spoofFieldAppVersion": "Версия приложения", + "spoofFieldBuildNumber": "Build Number", + "spoofFieldArchitecture": "Архитектура", + "spoofButtonGenerate": "Сгенерировать", + "spoofButtonApply": "Применить", + "spoofDialogUnsureTitle": "Ты уверен?", + "spoofDialogUnsureContent": "Приложение может начать работать нестабильно из-за несовместимости API", + "spoofDialogCancel": "Отмена", + "spoofDialogYes": "Да", + "spoofDialogApplyTitle": "Применить настройки?", + "spoofDialogApplyContent": "Нужно перезайти в приложение, ок?", + "spoofDialogApplyDeny": "Не", + "spoofDialogApplyConfirm": "Ок!", + "spoofErrorApplyFailed": "Ошибка при применении настроек: {error}", + "@spoofErrorApplyFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "profileMenuSpoof": "Подмена данных" } From 44dfb60933d4f1f4eb38f670cc1c1a9e0f188680 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 11 Apr 2026 00:25:15 +0300 Subject: [PATCH 54/59] fix(spoof): trim preset locale to 2-letter code --- lib/frontend/screens/profile/spoof_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index b5ca254..b8bd257 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -182,7 +182,7 @@ class _SpoofScreenState extends State { if (_selectedMethod == SpoofingMethod.full) { _timezoneController.text = preset.timezone; - _localeController.text = preset.locale; + _localeController.text = preset.locale.split(RegExp(r'[-_]')).first; } }); From 4916c24bb9126200f39da6c32a2f2bbe03aae8ab Mon Sep 17 00:00:00 2001 From: klockky <108465095+klockky@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:46:34 +0300 Subject: [PATCH 55/59] Add GitHub Actions workflow for Android build This workflow builds the Android application, including generating APKs and an AAB for release. --- .github/workflows/build-android.yml | 91 +++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .github/workflows/build-android.yml diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml new file mode 100644 index 0000000..380cd38 --- /dev/null +++ b/.github/workflows/build-android.yml @@ -0,0 +1,91 @@ +name: Build Android + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'lib/**' + - 'android/**' + - 'pubspec.yaml' + - '.github/workflows/build-android.yml' + pull_request: + paths: + - 'lib/**' + - 'android/**' + - 'pubspec.yaml' + - '.github/workflows/build-android.yml' + +jobs: + build-android: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v3 + + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.5' + channel: 'stable' + + - name: Get dependencies + run: flutter pub get + + - name: Configure Gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx1536m" >> ~/.gradle/gradle.properties + + - name: Build Universal APK + run: flutter build apk --release + + - name: Build Split APKs + run: flutter build apk --release --split-per-abi + + - name: Upload Universal APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-universal + path: build/app/outputs/flutter-apk/app-release.apk + retention-days: 30 + + - name: Upload arm64-v8a APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-arm64-v8a + path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk + retention-days: 30 + + - name: Upload armeabi-v7a APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-armeabi-v7a + path: build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk + retention-days: 30 + + - name: Upload x86_64 APK + uses: actions/upload-artifact@v4 + with: + name: komet-android-x86_64 + path: build/app/outputs/flutter-apk/app-x86_64-release.apk + retention-days: 30 + + - name: Build App Bundle + run: flutter build appbundle --release + + - name: Upload App Bundle artifact + uses: actions/upload-artifact@v4 + with: + name: komet-android-aab + path: build/app/outputs/bundle/release/app-release.aab + retention-days: 30 From 6f2dae7032ae36cfac01bbf42df72c10a33171c0 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 12 Apr 2026 12:18:12 +0700 Subject: [PATCH 56/59] =?UTF-8?q?=D0=BD=D0=B5=D0=BA=D0=BE=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/storage/spoofing_service.dart | 5 +- .../screens/profile/spoof_screen.dart | 70 +++++-------------- 2 files changed, 18 insertions(+), 57 deletions(-) diff --git a/lib/core/storage/spoofing_service.dart b/lib/core/storage/spoofing_service.dart index 21a766d..b58cce2 100644 --- a/lib/core/storage/spoofing_service.dart +++ b/lib/core/storage/spoofing_service.dart @@ -18,10 +18,9 @@ class SpoofingService { 'locale': prefs.getString('spoof_locale'), 'device_id': prefs.getString('spoof_deviceid'), 'device_type': prefs.getString('spoof_devicetype'), - 'app_version': prefs.getString('spoof_appversion') ?? hardcodedAppVersion, + 'app_version': hardcodedAppVersion, 'arch': prefs.getString('spoof_arch') ?? 'arm64-v8a', - 'build_number': - prefs.getInt('spoof_buildnumber') ?? hardcodedBuildNumber, + 'build_number': hardcodedBuildNumber, }; } } diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index b8bd257..ff16a0c 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -23,8 +23,7 @@ class SpoofScreen extends StatefulWidget { class _SpoofScreenState extends State { static const String _hardcodedVersion = SpoofingService.hardcodedAppVersion; - static const int _hardcodedBuildNumber = - SpoofingService.hardcodedBuildNumber; + static const int _hardcodedBuildNumber = SpoofingService.hardcodedBuildNumber; final _random = Random(); final _deviceNameController = TextEditingController(); @@ -219,7 +218,6 @@ class _SpoofScreenState extends State { 'device_id': prefs.getString('spoof_deviceid') ?? '', 'device_type': prefs.getString('spoof_devicetype') ?? 'ANDROID', 'arch': prefs.getString('spoof_arch') ?? '', - 'build_number': prefs.getInt('spoof_buildnumber')?.toString() ?? '', }; final newValues = { @@ -231,13 +229,8 @@ class _SpoofScreenState extends State { 'device_id': _deviceIdController.text, 'device_type': _selectedDeviceType, 'arch': _selectedArch, - 'build_number': _buildNumberController.text, }; - final oldAppVersion = - prefs.getString('spoof_appversion') ?? _hardcodedVersion; - final newAppVersion = _appVersionController.text; - bool otherDataChanged = false; for (final key in oldValues.keys) { if (oldValues[key] != newValues[key]) { @@ -246,33 +239,7 @@ class _SpoofScreenState extends State { } } - final appVersionChanged = oldAppVersion != newAppVersion; - final isChangingAwayFromHardcoded = newAppVersion != _hardcodedVersion; - - if (appVersionChanged && isChangingAwayFromHardcoded) { - if (!mounted) return; - final l10n = AppLocalizations.of(context)!; - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(l10n.spoofDialogUnsureTitle), - content: Text(l10n.spoofDialogUnsureContent), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text(l10n.spoofDialogCancel), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text(l10n.spoofDialogYes), - ), - ], - ), - ); - if (confirmed != true) return; - } - - if (appVersionChanged && !otherDataChanged) { + if (!otherDataChanged) { await _saveAllData(prefs); if (mounted) Navigator.of(context).pop(); return; @@ -329,12 +296,7 @@ class _SpoofScreenState extends State { await prefs.setString('spoof_locale', _localeController.text); await prefs.setString('spoof_deviceid', _deviceIdController.text); await prefs.setString('spoof_devicetype', _selectedDeviceType); - await prefs.setString('spoof_appversion', _appVersionController.text); await prefs.setString('spoof_arch', _selectedArch); - await prefs.setInt( - 'spoof_buildnumber', - int.tryParse(_buildNumberController.text) ?? _hardcodedBuildNumber, - ); } void _generateNewDeviceId() { @@ -360,10 +322,7 @@ class _SpoofScreenState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Scaffold( - appBar: AppBar( - title: Text(l10n.spoofScreenTitle), - centerTitle: true, - ), + appBar: AppBar(title: Text(l10n.spoofScreenTitle), centerTitle: true), body: _isLoading ? const Center(child: CircularProgressIndicator()) : SingleChildScrollView( @@ -642,18 +601,21 @@ class _SpoofScreenState extends State { TextField( controller: _deviceIdController, decoration: - _inputDecoration(l10n.spoofFieldDeviceId, Icons.tag_outlined) - .copyWith( - suffixIcon: IconButton( - icon: const Icon(Icons.autorenew_outlined), - tooltip: l10n.spoofRegenerateIdTooltip, - onPressed: _generateNewDeviceId, - ), - ), + _inputDecoration( + l10n.spoofFieldDeviceId, + Icons.tag_outlined, + ).copyWith( + suffixIcon: IconButton( + icon: const Icon(Icons.autorenew_outlined), + tooltip: l10n.spoofRegenerateIdTooltip, + onPressed: _generateNewDeviceId, + ), + ), ), const SizedBox(height: 16), TextField( controller: _appVersionController, + enabled: false, decoration: _inputDecoration( l10n.spoofFieldAppVersion, Icons.info_outline_rounded, @@ -662,6 +624,7 @@ class _SpoofScreenState extends State { const SizedBox(height: 16), TextField( controller: _buildNumberController, + enabled: false, keyboardType: TextInputType.number, decoration: _inputDecoration( l10n.spoofFieldBuildNumber, @@ -693,8 +656,7 @@ class _SpoofScreenState extends State { _ChipOption('arm64', 'arm64', Icons.memory_outlined), ], selected: _selectedArch, - onSelected: (value) => - setState(() => _selectedArch = value), + onSelected: (value) => setState(() => _selectedArch = value), ), ], ), From 24b7ff6affbf0416dcd16edb94e7d063ff80ceef Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 12 Apr 2026 14:18:26 +0700 Subject: [PATCH 57/59] =?UTF-8?q?=D0=BD=D0=B0=D1=85=D1=83=D0=B5=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=82=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 16 ++- lib/backend/modules/account.dart | 4 +- lib/core/protocol/packet.dart | 4 + .../screens/chats/chat_list_screen.dart | 20 +++- .../screens/profile/spoof_screen.dart | 113 +++++++++++------- lib/l10n/app_en.arb | 8 +- lib/l10n/app_localizations.dart | 38 +++++- lib/l10n/app_localizations_en.dart | 22 +++- lib/l10n/app_localizations_ru.dart | 22 +++- lib/l10n/app_ru.arb | 8 +- lib/main.dart | 20 ++-- 11 files changed, 213 insertions(+), 62 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 0ff3ace..2c50950 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -32,6 +32,7 @@ class Api { final _stateController = StreamController.broadcast(); final _sessionExpiredController = StreamController.broadcast(); + final _handshakeSuccessController = StreamController.broadcast(); Map? _userAgent; Map? get userAgent => _userAgent; @@ -44,6 +45,8 @@ class Api { Stream get stateStream => _stateController.stream; Stream get sessionExpiredStream => _sessionExpiredController.stream; + Stream get handshakeSuccessStream => + _handshakeSuccessController.stream; SessionState get state => _sessionState; StreamSubscription? _dataSubscription; @@ -93,6 +96,12 @@ class Api { _setSessionState(SessionState.online); _startPinging(); logger.i('Сессия онлайн, хэндшейк ок'); + _handshakeSuccessController.add( + response.payload['device_name'] as String? ?? 'Unknown', + ); + if (_onReconnectCallback != null) { + _onReconnectCallback!(); + } } else { logger.e('Хэндшейк отклонён: ${response.payload}'); } @@ -254,7 +263,8 @@ class Api { await for (final packet in _receiver.feed(data)) { if (packet.isError && packet.payload is Map && - packet.payload['message'] == 'FAIL_LOGIN_TOKEN') { + (packet.payload['message'] == 'FAIL_LOGIN_TOKEN' || + packet.payload['message'] == 'FAIL_WRONG_PASSWORD')) { _sessionExpiredController.add( SessionExpiredException(messageFromErrorPayload(packet.payload)), ); @@ -277,13 +287,11 @@ class Api { _socketStateSubscription = null; _receiver.reset(); _dispatcher.clearPending(); + _handshakeSuccessController.add('disconnected'); } Future reconnectAndLogin() async { await connect(); - if (_sessionState == SessionState.online && _onReconnectCallback != null) { - _onReconnectCallback!(); - } } void Function()? _onReconnectCallback; diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index d86108f..1f57233 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1022,7 +1022,9 @@ class AccountModule { void _checkPacketError(Packet packet, String method) { if (packet.isError) { final payload = packet.payload; - if (payload is Map && payload['message'] == 'FAIL_LOGIN_TOKEN') { + if (payload is Map && + (payload['message'] == 'FAIL_LOGIN_TOKEN' || + payload['message'] == 'FAIL_WRONG_PASSWORD')) { throw SessionExpiredException(messageFromErrorPayload(payload)); } throw PacketError(messageFromErrorPayload(payload)); diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index 7a0a320..7b6fb38 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -65,6 +65,10 @@ class SessionExpiredException extends PacketError { String messageFromErrorPayload(dynamic payload) { if (payload is Map) { + final msg = payload['message']; + if (msg == 'FAIL_WRONG_PASSWORD' || msg == 'FAIL_LOGIN_TOKEN') { + return 'Ваш токен был отклонён сервером, хм... Попробуйте войти ещё раз.'; + } for (final key in ['localizedMessage', 'message', 'title']) { final v = payload[key]; if (v is String && v.trim().isNotEmpty) return v.trim(); diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 1f1bf57..2f2bf70 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -199,7 +199,20 @@ class _ChatListScreenState extends State Future _reloadChatsAndFolders() async { final p = await AppDatabase.loadActiveProfile(); - if (p != null) { + if (p == null) { + _syncFolderChatScrollControllersForCount(1); + if (mounted) { + setState(() { + _folders = []; + _selectedFolderId = null; + _foldersListKnown = null; + _isInitialLoading = false; + }); + } + return; + } + + try { final chats = await ChatsModule.getChats(p.id); var folders = await FoldersModule.loadFolders(p.id); final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); @@ -244,7 +257,7 @@ class _ChatListScreenState extends State _jumpFolderPageToSelection(); }); } - } else { + } catch (_) { _syncFolderChatScrollControllersForCount(1); if (mounted) { setState(() { @@ -253,6 +266,9 @@ class _ChatListScreenState extends State _foldersListKnown = null; _isInitialLoading = false; }); + } + } finally { + if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _jumpFolderPageToSelection(); diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index ff16a0c..1a1f724 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -9,8 +9,10 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../core/config/device_presets.dart'; import '../../../core/storage/spoofing_service.dart'; +import '../../../core/storage/token_storage.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../auth/login_screen.dart'; enum SpoofingMethod { partial, full } @@ -127,19 +129,10 @@ class _SpoofScreenState extends State { _deviceNameController.text = '${androidInfo.manufacturer} ${androidInfo.model}'; _osVersionController.text = 'Android ${androidInfo.version.release}'; - _selectedDeviceType = 'ANDROID'; _selectedArch = androidInfo.supportedAbis.isNotEmpty ? androidInfo.supportedAbis.first : 'arm64-v8a'; _buildNumberController.text = '$_hardcodedBuildNumber'; - } else if (Platform.isIOS) { - final iosInfo = await deviceInfo.iosInfo; - _deviceNameController.text = iosInfo.name; - _osVersionController.text = - '${iosInfo.systemName} ${iosInfo.systemVersion}'; - _selectedDeviceType = 'IOS'; - _selectedArch = 'arm64'; - _buildNumberController.text = '$_hardcodedBuildNumber'; } else { await _applyGeneratedData(); } @@ -168,15 +161,7 @@ class _SpoofScreenState extends State { _appVersionController.text = _hardcodedVersion; _deviceIdController.text = _generateDeviceId(); - _selectedDeviceType = preset.deviceType; - - if (preset.deviceType == 'ANDROID') { - _selectedArch = 'arm64-v8a'; - } else if (preset.deviceType == 'IOS') { - _selectedArch = 'arm64'; - } else { - _selectedArch = 'x86_64'; - } + _selectedArch = 'arm64-v8a'; _buildNumberController.text = '$_hardcodedBuildNumber'; if (_selectedMethod == SpoofingMethod.full) { @@ -247,32 +232,76 @@ class _SpoofScreenState extends State { if (!mounted) return; final l10n = AppLocalizations.of(context)!; - final confirmed = await showDialog( + final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.spoofDialogApplyTitle), - content: Text(l10n.spoofDialogApplyContent), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.spoofDialogApplyContent), + const SizedBox(height: 12), + Text( + l10n.spoofDialogApplyWarning, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w500, + ), + ), + ], + ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => Navigator.of(context).pop('cancel'), child: Text(l10n.spoofDialogApplyDeny), ), + TextButton( + onPressed: () => Navigator.of(context).pop('relogin'), + child: Text(l10n.spoofDialogReloginConfirm), + ), FilledButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => Navigator.of(context).pop('apply'), child: Text(l10n.spoofDialogApplyConfirm), ), ], ), ); - if (confirmed != true || !mounted) return; + if (!mounted || confirmed == null) return; + + if (confirmed == 'relogin') { + final prefs = await SharedPreferences.getInstance(); + await _saveAllData(prefs); + await api.disconnect(); + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await TokenStorage.deleteToken(accountId); + } + await prefs.setBool('spoofing_enabled', true); + await api.connect(); + if (mounted) { + final navState = KometApp.navigatorKey.currentState; + if (navState != null) { + await navState.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (route) => false, + ); + } + } + return; + } + + if (confirmed != 'apply') return; await _saveAllData(prefs); try { await api.disconnect(); await api.connect(); - if (mounted) Navigator.of(context).pop(); + if (mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -452,29 +481,18 @@ class _SpoofScreenState extends State { text: l10n.spoofDeviceTypeDescription, ), const SizedBox(height: 12), - _buildChipSelector( - options: const [ - _ChipOption('ANDROID', 'ANDROID', Icons.android_outlined), - _ChipOption('IOS', 'iOS', Icons.phone_iphone_outlined), - _ChipOption( - 'DESKTOP', + Row( + children: [ + _buildDisabledChip('ANDROID', Icons.android_outlined, theme), + const SizedBox(width: 8), + _buildDisabledChip('iOS', Icons.phone_iphone_outlined, theme), + const SizedBox(width: 8), + _buildDisabledChip( 'Desktop', Icons.desktop_windows_outlined, + theme, ), ], - selected: _selectedDeviceType, - onSelected: (value) { - setState(() { - _selectedDeviceType = value; - if (value == 'ANDROID') { - _selectedArch = 'arm64-v8a'; - } else if (value == 'IOS') { - _selectedArch = 'arm64'; - } else { - _selectedArch = 'x86_64'; - } - }); - }, ), ], ), @@ -482,6 +500,15 @@ class _SpoofScreenState extends State { ); } + Widget _buildDisabledChip(String label, IconData icon, ThemeData theme) { + return Chip( + label: Text(label), + avatar: Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant), + backgroundColor: theme.colorScheme.surfaceContainerHighest, + side: BorderSide(color: theme.colorScheme.outlineVariant), + ); + } + Widget _buildDescriptionTile({ required IconData icon, required Color color, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c047aaa..806d0c5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -64,7 +64,7 @@ "spoofMethodPartialDescription": "Recommended method. Random data is used, but your real timezone and locale are kept for plausibility.", "spoofMethodFullDescription": "All data including timezone and locale is generated randomly. Use this method at your own risk!", "spoofDeviceTypeTitle": "Device type", - "spoofDeviceTypeDescription": "Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.", + "spoofDeviceTypeDescription": "This field is not changeable, to avoid token association issues", "spoofDeviceTypeLabel": "Device type", "spoofMainSectionTitle": "Main data", "spoofFieldDeviceName": "Device name", @@ -88,6 +88,12 @@ "spoofDialogYes": "Yes", "spoofDialogApplyTitle": "Apply settings?", "spoofDialogApplyContent": "Need to reconnect the app, ok?", + "spoofDialogApplyWarning": "Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible", + "spoofDialogReloginTitle": "Done!", + "spoofDialogReloginContent": "Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.", + "spoofDialogReloginWarning": "Re-login now?", + "spoofDialogReloginDeny": "Later", + "spoofDialogReloginConfirm": "Re-login now", "spoofDialogApplyDeny": "No", "spoofDialogApplyConfirm": "Ok!", "spoofErrorApplyFailed": "Failed to apply settings: {error}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 15fbdb0..5bd5013 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -431,7 +431,7 @@ abstract class AppLocalizations { /// No description provided for @spoofDeviceTypeDescription. /// /// In en, this message translates to: - /// **'Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.'** + /// **'This field is not changeable, to avoid token association issues'** String get spoofDeviceTypeDescription; /// No description provided for @spoofDeviceTypeLabel. @@ -572,6 +572,42 @@ abstract class AppLocalizations { /// **'Need to reconnect the app, ok?'** String get spoofDialogApplyContent; + /// No description provided for @spoofDialogApplyWarning. + /// + /// In en, this message translates to: + /// **'Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible'** + String get spoofDialogApplyWarning; + + /// No description provided for @spoofDialogReloginTitle. + /// + /// In en, this message translates to: + /// **'Done!'** + String get spoofDialogReloginTitle; + + /// No description provided for @spoofDialogReloginContent. + /// + /// In en, this message translates to: + /// **'Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.'** + String get spoofDialogReloginContent; + + /// No description provided for @spoofDialogReloginWarning. + /// + /// In en, this message translates to: + /// **'Re-login now?'** + String get spoofDialogReloginWarning; + + /// No description provided for @spoofDialogReloginDeny. + /// + /// In en, this message translates to: + /// **'Later'** + String get spoofDialogReloginDeny; + + /// No description provided for @spoofDialogReloginConfirm. + /// + /// In en, this message translates to: + /// **'Re-login now'** + String get spoofDialogReloginConfirm; + /// No description provided for @spoofDialogApplyDeny. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4422b8f..bfce511 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -183,7 +183,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get spoofDeviceTypeDescription => - 'Choose a device type for preset generation. Tapping \"Generate\" will only use presets of the selected type.'; + 'This field is not changeable, to avoid token association issues'; @override String get spoofDeviceTypeLabel => 'Device type'; @@ -256,6 +256,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get spoofDialogApplyContent => 'Need to reconnect the app, ok?'; + @override + String get spoofDialogApplyWarning => + 'Your spoof will change immediately. But due to MAX specifics, you must re-login to the account for it to become visible'; + + @override + String get spoofDialogReloginTitle => 'Done!'; + + @override + String get spoofDialogReloginContent => + 'Due to MAX specifics, your spoof is changed, but changes will be visible only after re-login.'; + + @override + String get spoofDialogReloginWarning => 'Re-login now?'; + + @override + String get spoofDialogReloginDeny => 'Later'; + + @override + String get spoofDialogReloginConfirm => 'Re-login now'; + @override String get spoofDialogApplyDeny => 'No'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 6648921..7a1c8b4 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -185,7 +185,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get spoofDeviceTypeDescription => - 'Выберите тип устройства для генерации пресетов. При нажатии \"Сгенерировать\" будут использоваться только пресеты выбранного типа.'; + 'Данное поле не изменяемое, во избежании проблем с ассоциацией токена'; @override String get spoofDeviceTypeLabel => 'Тип устройства'; @@ -258,6 +258,26 @@ class AppLocalizationsRu extends AppLocalizations { @override String get spoofDialogApplyContent => 'Нужно перезайти в приложение, ок?'; + @override + String get spoofDialogApplyWarning => + 'Ваш спуф изменится сразу. Но из-за особенностей МАХ, для того что-бы это стало заметно, вы должны перелогиниться в аккаунт'; + + @override + String get spoofDialogReloginTitle => 'Готово!'; + + @override + String get spoofDialogReloginContent => + 'Из-за особенности МАХ, ваш спуф изменён, но видны изменения будут только при перезаходе в аккаунт.'; + + @override + String get spoofDialogReloginWarning => 'Перезайти сейчас?'; + + @override + String get spoofDialogReloginDeny => 'Позже'; + + @override + String get spoofDialogReloginConfirm => 'Перелогиниться сейчас'; + @override String get spoofDialogApplyDeny => 'Не'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2efbabb..0f2378c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -64,7 +64,7 @@ "spoofMethodPartialDescription": "Рекомендуемый метод. Используются случайные данные, но ваш реальный часовой пояс и локаль для большей правдоподобности.", "spoofMethodFullDescription": "Все данные, включая часовой пояс и локаль, генерируются случайно. Использование этого метода на ваш страх и риск!", "spoofDeviceTypeTitle": "Тип устройства", - "spoofDeviceTypeDescription": "Выберите тип устройства для генерации пресетов. При нажатии \"Сгенерировать\" будут использоваться только пресеты выбранного типа.", + "spoofDeviceTypeDescription": "Данное поле не изменяемое, во избежании проблем с ассоциацией токена", "spoofDeviceTypeLabel": "Тип устройства", "spoofMainSectionTitle": "Основные данные", "spoofFieldDeviceName": "Имя устройства", @@ -88,6 +88,12 @@ "spoofDialogYes": "Да", "spoofDialogApplyTitle": "Применить настройки?", "spoofDialogApplyContent": "Нужно перезайти в приложение, ок?", + "spoofDialogApplyWarning": "Ваш спуф изменится сразу. Но из-за особенностей МАХ, для того что-бы это стало заметно, вы должны перелогиниться в аккаунт", + "spoofDialogReloginTitle": "Готово!", + "spoofDialogReloginContent": "Из-за особенности МАХ, ваш спуф изменён, но видны изменения будут только при перезаходе в аккаунт.", + "spoofDialogReloginWarning": "Перезайти сейчас?", + "spoofDialogReloginDeny": "Позже", + "spoofDialogReloginConfirm": "Перелогиниться сейчас", "spoofDialogApplyDeny": "Не", "spoofDialogApplyConfirm": "Ок!", "spoofErrorApplyFailed": "Ошибка при применении настроек: {error}", diff --git a/lib/main.dart b/lib/main.dart index 82ae9b6..0ce4998 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,6 +36,7 @@ void main() async { await AppDatabase.init(); await api.connect(); final initialLocale = await _loadInitialLocale(); + final prefs = await SharedPreferences.getInstance(); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; runApp( @@ -70,8 +71,9 @@ class KometAppState extends State { late Locale _locale; bool _isLoggingOut = false; - late final ValueNotifier fpsOverlayEnabled = - ValueNotifier(widget.initialFpsOverlay); + late final ValueNotifier fpsOverlayEnabled = ValueNotifier( + widget.initialFpsOverlay, + ); @override void initState() { @@ -79,10 +81,14 @@ class KometAppState extends State { _locale = widget.initialLocale; api.setReconnectCallback(() async { - final accountId = await TokenStorage.getActiveAccountId(); - if (accountId != null) { - await accountModule.login(accountId: accountId); - } + try { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null && + await TokenStorage.readToken(accountId) != null) { + final token = await TokenStorage.readToken(accountId); + await accountModule.login(accountId: accountId, token: token); + } + } catch (_) {} }); api.sessionExpiredStream.listen((SessionExpiredException e) async { @@ -250,7 +256,7 @@ class _StartupScreenState extends State<_StartupScreen> { Future _tryAutoLogin() async { final accountId = await TokenStorage.getActiveAccountId(); - if (accountId == null) { + if (accountId == null || await TokenStorage.readToken(accountId) == null) { _goToLogin(); return; } From 0eae0edd67713303d10695e1e1a35966ed8288c5 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Wed, 15 Apr 2026 22:30:10 +0700 Subject: [PATCH 58/59] =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D1=80=D1=83=D0=BD=D1=86?= =?UTF-8?q?=D1=8B,=20=D0=B3=D0=BE=D0=B2=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B4=D0=B0=20=D0=BD=D0=BE=20=D1=8D=D1=82=D0=BE=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D0=BE=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 1 + lib/frontend/widgets/message_bubble.dart | 402 +++++++++++++++++++- lib/models/attachment.dart | 29 ++ 3 files changed, 430 insertions(+), 2 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index a05c853..d89d068 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -203,6 +203,7 @@ class _ChatScreenState extends State originalText: a.originalText, originalChatId: a.originalChatId, originalAttachments: a.originalAttachments, + originalContact: a.originalContact, ); } return a; diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 647da92..e6e2e45 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; import '../../models/attachment.dart'; @@ -80,11 +81,17 @@ class MessageBubble extends StatelessWidget { final first = message.attachments!.first; if (first is ForwardedMessageAttachment) { final fwd = first; + final hasContact = fwd.originalContact != null; final hasPhoto = fwd.originalAttachments != null && fwd.originalAttachments!.any((a) => a is PhotoAttachment); - return hasPhoto ? MessageType.attachment : MessageType.text; + final hasOther = + fwd.originalAttachments != null && + fwd.originalAttachments!.isNotEmpty; + if (hasContact || hasPhoto || hasOther) return MessageType.attachment; + return MessageType.text; } + if (first is ContactAttachment) return MessageType.attachment; if (first is UnknownAttachment) return MessageType.text; if (first.type == AttachmentType.audio) return MessageType.voice; return MessageType.attachment; @@ -324,8 +331,16 @@ class MessageBubble extends StatelessWidget { ? Colors.white : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final attachments = message.attachments; + final isForwardedContact = + attachments != null && + attachments.isNotEmpty && + attachments.first is ForwardedMessageAttachment && + (attachments.first as ForwardedMessageAttachment).originalContact != + null; + final forwarded = _getForwardedAttachment(); - final isForwarded = forwarded != null; + final isForwarded = forwarded != null && !isForwardedContact; return Row( mainAxisSize: MainAxisSize.min, @@ -498,15 +513,27 @@ class MessageBubble extends StatelessWidget { final first = attachments.first; if (first is ForwardedMessageAttachment) { final fwd = first; + if (fwd.originalContact != null) { + return _buildForwardedContactContent(context, fwd); + } final photos = fwd.originalAttachments ?.whereType() .toList(); if (photos != null && photos.isNotEmpty) { return _buildForwardedPhotoContent(context, fwd, photos); } + final files = fwd.originalAttachments; + if (files != null && files.isNotEmpty) { + return _buildForwardedGenericContent(context, fwd, files); + } return _buildTextContent(context); } + final contacts = attachments.whereType().toList(); + if (contacts.isNotEmpty) { + return _buildContactAttachment(context, contacts.first); + } + final photos = attachments.whereType().toList(); if (photos.isEmpty) { return _buildGenericAttachment(context, attachments.first); @@ -669,6 +696,136 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildForwardedFileContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List files, + ) { + final cs = Theme.of(context).colorScheme; + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ...files.map((file) => _buildFileAttachment(context, file)), + ], + ); + } + + Widget _buildForwardedGenericContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + List attachments, + ) { + debugPrint('DEBUG: _buildForwardedGenericContent called'); + debugPrint('DEBUG: attachments count: ${attachments.length}'); + for (var i = 0; i < attachments.length; i++) { + debugPrint('DEBUG: attachment[$i] type: ${attachments[i].runtimeType}'); + debugPrint('DEBUG: attachment[$i] type field: ${attachments[i].type}'); + } + + final cs = Theme.of(context).colorScheme; + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + ...attachments.map((a) { + if (a is FileAttachment) { + return _buildFileAttachment(context, a); + } + return const SizedBox.shrink(); + }), + ], + ); + } + Widget _buildSinglePhoto(BuildContext ctx, PhotoAttachment photo) { final imageUrl = photo.baseUrl ?? ''; final width = photo.width?.toDouble() ?? 200; @@ -1078,6 +1235,247 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildContactAttachment(BuildContext ctx, MessageAttachment contact) { + final cs = Theme.of(ctx).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final contactData = contact as ContactAttachment; + + final firstName = contactData.firstName ?? ''; + final lastName = contactData.lastName ?? ''; + final hasFirstName = firstName.isNotEmpty; + final hasLastName = lastName.isNotEmpty; + + final name = (hasFirstName || hasLastName) + ? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}' + .trim() + : (contactData.name ?? 'Contact'); + final photoUrl = contactData.photoUrl ?? contactData.baseUrl; + + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.65) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + final bgColor = isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA)); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(24), + ), + child: photoUrl != null && photoUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.network( + photoUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + ) + : Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name.isNotEmpty ? name : 'Contact', + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (contactData.phoneNumber != null) ...[ + const SizedBox(height: 2), + Text( + contactData.phoneNumber!, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildForwardedContactContent( + BuildContext context, + ForwardedMessageAttachment forwarded, + ) { + final cs = Theme.of(context).colorScheme; + final isDark = cs.brightness == Brightness.dark; + final contact = forwarded.originalContact!; + + final firstName = contact.firstName ?? ''; + final lastName = contact.lastName ?? ''; + final hasFirstName = firstName.isNotEmpty; + final hasLastName = lastName.isNotEmpty; + + final name = (hasFirstName || hasLastName) + ? '${hasFirstName ? firstName : ''}${hasLastName ? ' $lastName' : ''}' + .trim() + : (contact.name ?? 'Contact'); + final photoUrl = contact.photoUrl ?? contact.baseUrl; + + final textColor = isMe + ? Colors.white + : (isDark ? cs.onSurface : const Color(0xFF1C1C1E)); + final subtitleColor = isMe + ? Colors.white.withValues(alpha: 0.65) + : (isDark ? cs.onSurfaceVariant : const Color(0xFF8E8E93)); + final bgColor = isMe + ? Colors.white.withValues(alpha: 0.2) + : (isDark ? cs.surfaceContainerHighest : const Color(0xFFE5E5EA)); + final headerColor = isMe + ? Colors.white.withValues(alpha: 0.7) + : cs.onSurfaceVariant; + + final displaySender = + forwarded.originalSenderName ?? forwarded.originalSenderId.toString(); + final senderAvatar = forwarded.originalSenderAvatar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.forward, size: 14, color: headerColor), + const SizedBox(width: 4), + if (senderAvatar != null && senderAvatar.isNotEmpty) + CircleAvatar( + radius: 10, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else + CircleAvatar( + radius: 10, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ), + const SizedBox(width: 6), + Text( + displaySender, + style: TextStyle( + color: headerColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(24), + ), + child: photoUrl != null && photoUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.network( + photoUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + ) + : Icon( + Symbols.person, + color: isMe ? Colors.white : cs.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + name.isNotEmpty ? name : 'Contact', + style: TextStyle( + color: textColor, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (contact.phoneNumber != null) ...[ + const SizedBox(height: 2), + Text( + contact.phoneNumber!, + style: TextStyle( + color: subtitleColor, + fontSize: 12, + height: 1.2, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ], + ); + } + void _openPhotoViewer(BuildContext ctx, PhotoAttachment photo) { // TODO: Open photo viewer } diff --git a/lib/models/attachment.dart b/lib/models/attachment.dart index 2c43222..2d3b6b1 100644 --- a/lib/models/attachment.dart +++ b/lib/models/attachment.dart @@ -325,6 +325,9 @@ class ContactAttachment extends MessageAttachment { final String? firstName; final String? lastName; final String? phoneNumber; + final String? photoUrl; + final int? contactId; + final String? name; const ContactAttachment({ super.previewData, @@ -334,6 +337,9 @@ class ContactAttachment extends MessageAttachment { this.firstName, this.lastName, this.phoneNumber, + this.photoUrl, + this.contactId, + this.name, }) : super(type: AttachmentType.contact); factory ContactAttachment.fromMap(Map map) { @@ -344,6 +350,9 @@ class ContactAttachment extends MessageAttachment { firstName: map['firstName'] as String?, lastName: map['lastName'] as String?, phoneNumber: map['phoneNumber'] as String?, + photoUrl: map['photoUrl'] as String?, + contactId: map['contactId'] as int?, + name: map['name'] as String?, ); } @@ -356,6 +365,9 @@ class ContactAttachment extends MessageAttachment { 'firstName': firstName, 'lastName': lastName, 'phoneNumber': phoneNumber, + 'photoUrl': photoUrl, + 'contactId': contactId, + 'name': name, }; } @@ -442,6 +454,7 @@ class ForwardedMessageAttachment extends MessageAttachment { final String? originalText; final int? originalChatId; final List? originalAttachments; + final ContactAttachment? originalContact; const ForwardedMessageAttachment({ required this.originalSenderId, @@ -452,6 +465,7 @@ class ForwardedMessageAttachment extends MessageAttachment { this.originalText, this.originalChatId, this.originalAttachments, + this.originalContact, }) : super(type: AttachmentType.photo); factory ForwardedMessageAttachment.fromMap(Map map) { @@ -470,11 +484,25 @@ class ForwardedMessageAttachment extends MessageAttachment { } List? originalAttaches; + ContactAttachment? originalContact; if (message != null) { final attaches = message['attaches'] as List?; if (attaches != null) { + final contactAttaches = attaches + .whereType() + .where((a) => (a['_type'] as String?)?.toUpperCase() == 'CONTACT') + .toList(); + if (contactAttaches.isNotEmpty) { + originalContact = ContactAttachment.fromMap( + Map.from(contactAttaches.first), + ); + } originalAttaches = attaches .whereType() + .where((a) { + final type = (a['_type'] as String?)?.toUpperCase(); + return type != 'CONTACT'; + }) .map((a) => MessageAttachment.fromMap(Map.from(a))) .toList(); } @@ -487,6 +515,7 @@ class ForwardedMessageAttachment extends MessageAttachment { originalText: message?['text'] as String?, originalChatId: link?['chatId'] as int?, originalAttachments: originalAttaches, + originalContact: originalContact, ); } From ff8a4c1adec67ac0f3f103d1cc4146f5ab8ee22f Mon Sep 17 00:00:00 2001 From: InviseDivine Date: Wed, 15 Apr 2026 17:46:40 +0200 Subject: [PATCH 59/59] feat: users in chats list, avatars and nicknames, users count in chats --- lib/backend/modules/chats.dart | 249 ++++++++++-------- lib/core/storage/app_database.dart | 41 ++- .../screens/chats/chat_list_screen.dart | 114 +++++--- lib/frontend/screens/chats/chat_screen.dart | 16 +- lib/frontend/widgets/message_bubble.dart | 128 ++++++--- 5 files changed, 363 insertions(+), 185 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index f8c81d6..3b0fb27 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import '../../core/storage/app_database.dart'; +import '../../core/utils/logger.dart'; class CachedChat { final int id; @@ -17,6 +20,7 @@ class CachedChat { final int dontDisturbUntil; final bool isOnline; final int seenTime; + final Map participants; const CachedChat({ required this.id, @@ -35,6 +39,7 @@ class CachedChat { required this.dontDisturbUntil, required this.isOnline, required this.seenTime, + required this.participants, }); factory CachedChat.fromDbRow(Map row) => CachedChat( @@ -54,6 +59,8 @@ class CachedChat { dontDisturbUntil: row['dont_disturb_until'] as int, isOnline: (row['is_online'] as int) == 1, seenTime: row['seen_time'] as int, + // watafuc + participants: Map.from(jsonDecode(row['participants'])).map((k, v) => MapEntry(int.parse(k), v)) ); Map toDbRow() => { @@ -73,6 +80,7 @@ class CachedChat { 'dont_disturb_until': dontDisturbUntil, 'is_online': isOnline ? 1 : 0, 'seen_time': seenTime, + 'participants': jsonEncode(participants.map((k, v) => MapEntry(k.toString(), v))) }; } @@ -87,51 +95,72 @@ class ChatsModule { int accountId, int currentUserId, ) async { - final chats = data['chats']; - if (chats is! List || chats.isEmpty) return; + try { + final chats = data['chats']; + if (chats is! List || chats.isEmpty) return; - final contactsMap = _buildContactsMap(data['contacts']); - // Config contains mute setup and fav indexes: config -> chats -> id - final configMap = data['config'] is Map ? data['config'] as Map : {}; - final chatsConfig = configMap['chats'] is Map - ? configMap['chats'] as Map - : {}; - // Presence for online statuses - final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; - final cachedAt = DateTime.now().millisecondsSinceEpoch; + final contactsMap = _buildContactsMap(data['contacts']); + // Config contains mute setup and fav indexes: config -> chats -> id + final configMap = data['config'] is Map ? data['config'] as Map : {}; + final chatsConfig = configMap['chats'] is Map + ? configMap['chats'] as Map + : {}; + // Presence for online statuses + final presenceMap = data['presence'] is Map ? data['presence'] as Map : {}; + final cachedAt = DateTime.now().millisecondsSinceEpoch; - final existingRows = await AppDatabase.loadChats(accountId); - final existing = { - for (final row in existingRows) - row['id'] as int: CachedChat.fromDbRow(row), - }; + final existingRows = await AppDatabase.loadChats(accountId); + final existing = { + for (final row in existingRows) + row['id'] as int: CachedChat.fromDbRow(row), + }; - final rows = chats - .whereType() - .map( - (c) => _parseChat( - c.cast(), - accountId, - currentUserId, - contactsMap, - chatsConfig, - presenceMap, - existing, - cachedAt, - ), - ) - .whereType() - .map((c) => c.toDbRow()) - .toList(); + final rows = chats + .whereType() + .map( + (c) => _parseChat( + c.cast(), + accountId, + currentUserId, + contactsMap, + chatsConfig, + presenceMap, + existing, + cachedAt, + ), + ) + .whereType() + .map((c) => c.toDbRow()) + .toList(); - if (rows.isNotEmpty) { - await AppDatabase.saveChats(rows); + if (rows.isNotEmpty) { + await AppDatabase.saveChats(rows); + } + } catch (e) { + logger.e("Ошибка при синке: $e"); } } static Future> getChats(int accountId) async { - final rows = await AppDatabase.loadChats(accountId); - return rows.map(CachedChat.fromDbRow).toList(); + try { + final rows = await AppDatabase.loadChats(accountId); + + return rows.map(CachedChat.fromDbRow).toList(); + } catch (e) { + logger.e("Ошибка при получении чатов: $e"); + return []; + } + } + static Future> getChat(int accountId, int chatId) async { + try { + final rows = await AppDatabase.loadChat(accountId, chatId); + + return rows.map(CachedChat.fromDbRow).toList(); + } catch (e) { + logger.e("Ошибка при получении чата: $e"); + + return []; + } } static Future clearCache(int accountId) => @@ -157,80 +186,88 @@ class ChatsModule { Map existing, int cachedAt, ) { - final id = chat['id']; - if (id is! int) return null; + try { + final id = chat['id']; + if (id is! int) return null; - final type = (chat['type'] as String?) ?? 'DIALOG'; - int? otherId; + final type = (chat['type'] as String?) ?? 'DIALOG'; + int? otherId; - String? title; - String? iconUrl; + String? title; + String? iconUrl; - if (type == 'DIALOG') { - otherId = _otherParticipantId(chat['participants'], currentUserId); - final contact = otherId != null ? contactsMap[otherId] : null; + if (type == 'DIALOG') { + otherId = _otherParticipantId(chat['participants'], currentUserId); + final contact = otherId != null ? contactsMap[otherId] : null; - if (contact != null) { - title = _nameFromContact(contact); - iconUrl = contact['baseUrl'] as String?; - } else { - title = existing[id]?.title; - iconUrl = existing[id]?.iconUrl; - } - } else { - title = chat['title'] as String?; - iconUrl = chat['baseIconUrl'] as String?; + if (contact != null) { + title = _nameFromContact(contact); + iconUrl = contact['baseUrl'] as String?; + } else { + title = existing[id]?.title; + iconUrl = existing[id]?.iconUrl; + } + } else { + title = chat['title'] as String?; + iconUrl = chat['baseIconUrl'] as String?; + } + + final lastMsg = chat['lastMessage']; + int? lastMsgId; + int? lastMsgTime; + String? lastMsgText; + int? lastMsgSenderId; + + if (lastMsg is Map) { + lastMsgId = lastMsg['id'] as int?; + lastMsgTime = lastMsg['time'] as int?; + lastMsgText = lastMsg['text'] as String?; + lastMsgSenderId = lastMsg['sender'] as int?; + } + + final config = chatsConfig[id.toString()] ?? chatsConfig[id]; + int? favIndex; + int dontDisturbUntil = 0; + if (config is Map) { + favIndex = config['favIndex'] as int?; + dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; + } + + int seenTime = 0; + bool isOnline = false; + if (type == 'DIALOG' && otherId != null) { + final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; + if (presence is Map) { + seenTime = (presence['seen'] as int?) ?? 0; + isOnline = (presence['status'] as int?) == 1; + } + } + Map participants = Map.from(chat['participants']); + + return CachedChat( + id: id, + accountId: accountId, + type: type, + title: title, + iconUrl: iconUrl, + lastMsgId: lastMsgId, + lastMsgTime: lastMsgTime, + lastMsgText: lastMsgText, + lastMsgSenderId: lastMsgSenderId, + unreadCount: (chat['newMessages'] as int?) ?? 0, + lastEventTime: (chat['lastEventTime'] as int?) ?? 0, + cachedAt: cachedAt, + favIndex: favIndex, + dontDisturbUntil: dontDisturbUntil, + isOnline: isOnline, + seenTime: seenTime, + participants: participants + ); + } catch (e) { + logger.e("Ошибка при парсинге чата: $e"); + + return null; } - - final lastMsg = chat['lastMessage']; - int? lastMsgId; - int? lastMsgTime; - String? lastMsgText; - int? lastMsgSenderId; - - if (lastMsg is Map) { - lastMsgId = lastMsg['id'] as int?; - lastMsgTime = lastMsg['time'] as int?; - lastMsgText = lastMsg['text'] as String?; - lastMsgSenderId = lastMsg['sender'] as int?; - } - - final config = chatsConfig[id.toString()] ?? chatsConfig[id]; - int? favIndex; - int dontDisturbUntil = 0; - if (config is Map) { - favIndex = config['favIndex'] as int?; - dontDisturbUntil = (config['dontDisturbUntil'] as int?) ?? 0; - } - - int seenTime = 0; - bool isOnline = false; - if (type == 'DIALOG' && otherId != null) { - final presence = presenceMap[otherId.toString()] ?? presenceMap[otherId]; - if (presence is Map) { - seenTime = (presence['seen'] as int?) ?? 0; - isOnline = (presence['status'] as int?) == 1; - } - } - - return CachedChat( - id: id, - accountId: accountId, - type: type, - title: title, - iconUrl: iconUrl, - lastMsgId: lastMsgId, - lastMsgTime: lastMsgTime, - lastMsgText: lastMsgText, - lastMsgSenderId: lastMsgSenderId, - unreadCount: (chat['newMessages'] as int?) ?? 0, - lastEventTime: (chat['lastEventTime'] as int?) ?? 0, - cachedAt: cachedAt, - favIndex: favIndex, - dontDisturbUntil: dontDisturbUntil, - isOnline: isOnline, - seenTime: seenTime, - ); } static int? _otherParticipantId(dynamic participants, int currentUserId) { diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index f9bb88e..5890f21 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:komet/core/utils/logger.dart'; import 'package:path/path.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -138,7 +139,7 @@ class AppDatabase { final dbPath = await getDatabasesPath(); return openDatabase( join(dbPath, 'komet.db'), - version: 7, + version: 8, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -167,6 +168,11 @@ class AppDatabase { 'ALTER TABLE profile ADD COLUMN profile_options TEXT', ); } + if (oldVersion < 8) { + await db.execute( + 'ALTER TABLE chats_cache ADD COLUMN participants TEXT', + ); + } }, ); } @@ -235,6 +241,7 @@ class AppDatabase { dont_disturb_until INTEGER NOT NULL DEFAULT 0, is_online INTEGER NOT NULL DEFAULT 0, seen_time INTEGER NOT NULL DEFAULT 0, + participants TEXT NOT NULL DEFAULT "", PRIMARY KEY (id, account_id) ) '''; @@ -375,18 +382,32 @@ class AppDatabase { // Chats cache static Future saveChats(List> rows) async { - final db = await _instance; - final batch = db.batch(); - for (final row in rows) { - batch.insert( - 'chats_cache', - row, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + 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); + } catch (e) { + logger.e("Ошибка при сохранении чата: $e"); } - await batch.commit(noResult: true); } + static Future>> loadChat(int accountId, int chatId) async { + final db = await _instance; + return db.query( + 'chats_cache', + where: 'account_id = ? AND id = ?', + whereArgs: [accountId, chatId], + orderBy: 'last_event_time DESC', + ); + } + static Future>> loadChats(int accountId) async { final db = await _instance; return db.query( diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 2f2bf70..986cbf2 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:komet/backend/modules/messages.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'dart:math'; import 'dart:ui' as ui; @@ -15,7 +16,7 @@ import '../../../backend/modules/account.dart'; import '../../../backend/modules/chats.dart'; import '../../../backend/modules/folders.dart'; import '../../../core/storage/app_database.dart'; -import '../../../main.dart' show accountModule, api; +import '../../../main.dart' show accountModule, api, messagesModule; class _StoriesScrollPhysics extends BouncingScrollPhysics { final bool Function() blockPositive; @@ -60,40 +61,49 @@ class ChatListScreen extends StatefulWidget { class _ChatListScreenState extends State with TickerProviderStateMixin { String? _selectedFolderId; + List _folders = []; + int _currentNavIndex = 0; - bool _navDragging = false; - double _navDragDx = 0; - double _navDragBaseLeft = 0; - late AnimationController _navPageAnimController; + double _navPageAnimStart = 0; double _navPageAnimEnd = 0; - bool _isFabOpen = false; - bool _showCacheWarning = false; - late AnimationController _fabController; - final Set _selectedChats = {}; - late PageController _folderPageController; - final List _folderChatScrollControllers = []; - final List _folderChatScrollListenerFns = []; - double _pullRatio = 0.0; - static const double _kStoriesPullTriggerPx = 16.0; - late AnimationController _storiesRevealController; + double _navDragDx = 0; + double _navDragBaseLeft = 0; double _revealAnimBegin = 0.0; double _closeAnimBegin = 0.0; + double _pullRatio = 0.0; + static const double _kStoriesPullTriggerPx = 16.0; + + bool _navDragging = false; + bool _isFabOpen = false; + bool _showCacheWarning = false; bool _storiesAnimClosing = false; bool _storiesDockedOpen = false; bool _storiesOverscrollRevealArmed = true; + bool _shouldCollapseSearch = false; + bool get _isSelectionMode => _selectedChats.isNotEmpty; + bool? _foldersListKnown; + + late AnimationController _navPageAnimController; + late AnimationController _fabController; + late PageController _folderPageController; + late AnimationController _storiesRevealController; + + final List _folderChatScrollControllers = []; + final List _folderChatScrollListenerFns = []; + final Set _selectedChats = {}; + DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); ProfileData? _profile; + List _chats = []; + SessionState _sessionState = SessionState.disconnected; + StreamSubscription? _stateSub; StreamSubscription? _loginSub; - bool? _foldersListKnown; - bool _shouldCollapseSearch = false; - - bool get _isSelectionMode => _selectedChats.isNotEmpty; void _toggleSelection(String chatId) { setState(() { @@ -1015,18 +1025,60 @@ class _ChatListScreenState extends State return _buildChatShimmer(); } final chat = chats[index]; - return _buildChatItem( - chat.id.toString(), - chat.title ?? 'Чат', - chat.lastMsgText ?? '', - _formatTime(chat.lastMsgTime), - (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) - ? chat.iconUrl! - : '', - isOnline: chat.isOnline, - unreadCount: chat.unreadCount, - isMuted: chat.dontDisturbUntil > 0, - ); + + if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { + final secondId = chat.participants.entries.where((entry) => entry.key != _profile?.id).first.key; + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(secondId); + final name = ContactCache.get(secondId); + final avatar = ContactCache.getAvatar(secondId); + + return _buildChatItem( + chat.id.toString(), + name ?? "Пользователь", + chat.lastMsgText?.replaceAll('\n', ' ') ?? '', + _formatTime(chat.lastMsgTime), + avatar ?? "", + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + } else { + if (chat.lastMsgSenderId != null ) { + final ss = messagesModule.searchContactById(chat.lastMsgSenderId!); + } + + final name = chat.lastMsgSenderId != null + ? ContactCache.get(chat.lastMsgSenderId!) + : null; + + final avatar = chat.lastMsgSenderId != null + ? ContactCache.getAvatar(chat.lastMsgSenderId!) + : null; + + String fullMsg = ""; + + if (name?.isNotEmpty == true && chat.id != 0) { + fullMsg += "$name: "; + } + + if (chat.lastMsgText?.isNotEmpty == true) { + fullMsg += chat.lastMsgText ?? ""; + } + + return _buildChatItem( + chat.id.toString(), + chat.id == 0 ? "Избранное" : chat.title ?? "Чат", + fullMsg, + _formatTime(chat.lastMsgTime), + (chat.iconUrl != null && chat.iconUrl!.isNotEmpty) + ? chat.iconUrl! + : '', + isOnline: chat.isOnline, + unreadCount: chat.unreadCount, + isMuted: chat.dontDisturbUntil > 0, + ); + } }, childCount: _isInitialLoading ? 10 : chats.length), ), SliverPadding( diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index a05c853..40a0359 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 'package:flutter/material.dart'; +import 'package:komet/backend/modules/chats.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; @@ -35,7 +36,8 @@ class _ChatScreenState extends State late AnimationController _shimmerController; List _messages = []; int _myId = 0; - + CachedChat? chat; + @override void initState() { super.initState(); @@ -51,6 +53,11 @@ class _ChatScreenState extends State Future _loadHistory() async { final activeProfile = await AppDatabase.loadActiveProfile(); _myId = activeProfile?.id ?? 0; + ChatsModule.getChat(_myId, widget.chatId).then((value) { + chat = value[0]; + }).catchError((error) { + + }); final cachedRows = await AppDatabase.loadMessages( _myId, @@ -240,6 +247,10 @@ class _ChatScreenState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + + // TODO: Локализация + // TODO: Cклонения + String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently"; return Scaffold( backgroundColor: cs.surface, appBar: AppBar( @@ -284,7 +295,7 @@ class _ChatScreenState extends State ), ), Text( - 'last seen recently', + status ?? "", style: TextStyle( color: cs.onSurfaceVariant, fontSize: 12, @@ -353,6 +364,7 @@ class _ChatScreenState extends State myId: _myId, prevMessage: prevMessage, nextMessage: nextMessage, + chatType: chat!.type, ); }, ); diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 647da92..bbc93f9 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/main.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../backend/modules/messages.dart'; import '../../models/attachment.dart'; @@ -37,6 +40,7 @@ class MessageBubble extends StatelessWidget { final int myId; final CachedMessage? prevMessage; final CachedMessage? nextMessage; + final String chatType; const MessageBubble({ super.key, @@ -45,6 +49,7 @@ class MessageBubble extends StatelessWidget { required this.myId, this.prevMessage, this.nextMessage, + required this.chatType }); bool get isGroupedWithNext { @@ -277,6 +282,11 @@ class MessageBubble extends StatelessWidget { final cs = Theme.of(context).colorScheme; final isDark = cs.brightness == Brightness.dark; + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(message.senderId); + String? senderAvatar = ContactCache.getAvatar(message.senderId); + String? displaySender = ContactCache.get(message.senderId); + return Padding( padding: EdgeInsets.only( left: isMe ? 60 : 12, @@ -285,22 +295,52 @@ class MessageBubble extends StatelessWidget { bottom: bottomMargin, ), child: Align( - alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, - ), - decoration: BoxDecoration( - color: isMe - ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) - : (isDark - ? cs.surfaceContainerHighest - : const Color(0xFFE9E9EB)), - borderRadius: _borderRadius, - ), - padding: padding, - child: _buildContent(context), - ), + child: Row( + mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, + spacing: 8.0, + children: [ + if (senderAvatar != null && senderAvatar.isNotEmpty && !isMe && chatType != "DIALOG" + && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + CircleAvatar( + radius: 15, + backgroundImage: NetworkImage(senderAvatar), + backgroundColor: cs.primaryContainer, + ) + else if (displaySender != null && !isMe && chatType != "DIALOG" + && nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId) + CircleAvatar( + radius: 15, + backgroundColor: cs.primaryContainer, + child: Text( + displaySender!.isNotEmpty + ? displaySender[0].toUpperCase() + : '?', + style: TextStyle(fontSize: 9, color: cs.onPrimaryContainer), + ), + ) + // Заглушка для паддинга + else + CircleAvatar( + radius: 15, + backgroundColor: Color(0x00000000) + ), + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + decoration: BoxDecoration( + color: isMe + ? (isDark ? const Color(0xFF2C5F8D) : const Color(0xFF007AFF)) + : (isDark + ? cs.surfaceContainerHighest + : const Color(0xFFE9E9EB)), + borderRadius: _borderRadius, + ), + padding: padding, + child: _buildContent(context), + ), + ], + ) ), ); } @@ -327,30 +367,46 @@ class MessageBubble extends StatelessWidget { final forwarded = _getForwardedAttachment(); final isForwarded = forwarded != null; - return Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, + // TODO: Нормальное кеширование контактов + final ss = messagesModule.searchContactById(message.senderId); + String? displaySender = ContactCache.get(message.senderId); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible( - child: isForwarded - ? _buildForwardedInlineText(context, forwarded, textColor) - : Text( - message.text ?? '', - style: TextStyle(color: textColor, fontSize: 16, height: 1.3), - ), + if (message.senderId != message.accountId && prevMessage?.senderId != message.senderId) + Text( + displaySender ?? "", + textAlign: TextAlign.left, + // TODO: Получение цветов по хешу ника + style: TextStyle(color: cs.onPrimaryContainer) ), - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - _formatTime(message.time), - style: TextStyle( - color: textColor.withValues(alpha: 0.7), - fontSize: 10, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: isForwarded + ? _buildForwardedInlineText(context, forwarded, textColor) + : Text( + message.text ?? '', + style: TextStyle(color: textColor, fontSize: 16, height: 1.3), + ), ), - ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + _formatTime(message.time), + style: TextStyle( + color: textColor.withValues(alpha: 0.7), + fontSize: 10, + ), + ), + ), + if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], + ], ), - if (isMe) ...[const SizedBox(width: 4), _buildStatusIcon(context)], ], ); }