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: