я как бы еще не доделал, это такой промежуточный пуш знаете

This commit is contained in:
Jganenokk
2026-04-03 19:35:45 +07:00
parent 98c72e5d16
commit 91069f53ea
17 changed files with 987 additions and 417 deletions
+49 -61
View File
@@ -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<SessionState>.broadcast();
Map<dynamic, dynamic>? _userAgent;
Map<dynamic, dynamic>? get userAgent => _userAgent;
Stream<SessionState> get stateStream => _stateController.stream;
SessionState get state => _sessionState;
@@ -98,86 +96,77 @@ class Api {
_setSessionState(SessionState.disconnected);
}
/// Отправляет хэндшейк (opcode 6).
Future<Packet> 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 = <dynamic, dynamic>{
'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<Packet> sendRequest(
int opcode,
Map<dynamic, dynamic> payload,
) {
Future<Packet> sendRequest(int opcode, Map<dynamic, dynamic> 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;
+30 -20
View File
@@ -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<dynamic, dynamic> raw;
const LoginResult({
required this.profile,
required this.updatedToken,
required this.serverTime,
required this.raw,
});
}
@@ -131,14 +141,12 @@ class AccountModule {
Future<RequestCodeResult> requestCode(
String phone, {
String language = 'ru',
}) =>
_requestCodeInternal(phone, AuthRequestType.startAuth, language);
}) => _requestCodeInternal(phone, AuthRequestType.startAuth, language);
Future<RequestCodeResult> resendCode(
String phone, {
String language = 'ru',
}) =>
_requestCodeInternal(phone, AuthRequestType.resend, language);
}) => _requestCodeInternal(phone, AuthRequestType.resend, language);
Future<VerifyCodeResult> 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<dynamic, dynamic>());
@@ -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 = <dynamic, dynamic>{
'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<dynamic, dynamic>());
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());
}
}
}
+96 -42
View File
@@ -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<String, dynamic> 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<String, dynamic> 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>()
.map((c) => _parseChat(
c.cast<dynamic, dynamic>(),
accountId,
currentUserId,
contactsMap,
existing,
cachedAt,
))
.map(
(c) => _parseChat(
c.cast<dynamic, dynamic>(),
accountId,
currentUserId,
contactsMap,
chatsConfig,
presenceMap,
existing,
cachedAt,
),
)
.whereType<CachedChat>()
.map((c) => c.toDbRow())
.toList();
@@ -109,7 +137,7 @@ class ChatsModule {
static Future<void> clearCache(int accountId) =>
AppDatabase.clearChatsCache(accountId);
// internal
// internal
static Map<int, Map<dynamic, dynamic>> _buildContactsMap(dynamic contacts) {
if (contacts is! List) return {};
@@ -126,6 +154,8 @@ class ChatsModule {
int accountId,
int currentUserId,
Map<int, Map<dynamic, dynamic>> contactsMap,
Map<dynamic, dynamic> chatsConfig,
Map<dynamic, dynamic> presenceMap,
Map<int, CachedChat> 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<dynamic, dynamic> 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?;
}
}
+56
View File
@@ -0,0 +1,56 @@
import '../../core/storage/app_database.dart';
class ContactsModule {
static Future<void> syncFromLoginPayload(
Map<dynamic, dynamic> data,
int accountId,
) async {
final contacts = data['contacts'];
if (contacts is! List || contacts.isEmpty) return;
final rows = contacts
.whereType<Map>()
.map((c) => _parseContact(c.cast<dynamic, dynamic>(), accountId))
.whereType<Map<String, dynamic>>()
.toList();
if (rows.isNotEmpty) {
await AppDatabase.saveContacts(rows);
}
}
static Map<String, dynamic>? _parseContact(
Map<dynamic, dynamic> 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,
};
}
}
+83 -29
View File
@@ -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<String, dynamic> 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<ProfileData?> 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<String?> getSyncValue(int accountId, String key) async {
@@ -281,13 +305,17 @@ class AppDatabase {
_db = null;
}
// Chats cache
// Chats cache
static Future<void> saveChats(List<Map<String, dynamic>> 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<void> 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<void> saveContacts(List<Map<String, dynamic>> 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<List<Map<String, dynamic>>> loadContacts(int accountId) async {
final db = await _instance;
return db.query(
'contacts',
where: 'account_id = ?',
whereArgs: [accountId],
);
}
}
+21 -16
View File
@@ -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<void> saveToken(String token, int accountId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('$_tokenPrefix$accountId', token);
}
static Future<void> saveToken(String token, int accountId) =>
_storage.write(key: '$_tokenPrefix$accountId', value: token);
static Future<String?> readToken(int accountId) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('$_tokenPrefix$accountId');
}
static Future<String?> readToken(int accountId) =>
_storage.read(key: '$_tokenPrefix$accountId');
static Future<void> deleteToken(int accountId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('$_tokenPrefix$accountId');
}
static Future<void> deleteToken(int accountId) =>
_storage.delete(key: '$_tokenPrefix$accountId');
static Future<void> setActiveAccount(int accountId) =>
_storage.write(key: _activeAccountKey, value: accountId.toString());
static Future<void> setActiveAccount(int accountId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_activeAccountKey, accountId.toString());
}
static Future<int?> 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<void> 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);
}
}
}
+6 -3
View File
@@ -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);
}
+3 -2
View File
@@ -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;
}
}
@@ -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<CodeConfirmationScreen> createState() => _CodeConfirmationScreenState();
}
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
with TickerProviderStateMixin {
final TextEditingController _codeController = TextEditingController();
final FocusNode _focusNode = FocusNode();
int _timerSeconds = 30;
Timer? _timer;
Timer? _errorTimer;
String? _errorMessage;
late AnimationController _shakeController;
late Animation<double> _shakeAnimation;
@override
void initState() {
@@ -33,11 +40,26 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
_shakeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_shakeAnimation = TweenSequence<double>([
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<CodeConfirmationScreen> {
});
}
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<CodeConfirmationScreen> {
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<CodeConfirmationScreen> {
);
} 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<CodeConfirmationScreen> {
),
),
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<double> animation) {
return ScaleTransition(
scale: animation,
child: FadeTransition(opacity: animation, child: child),
);
},
child: Text(
char,
key: ValueKey<String>(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<double> animation,
) {
return ScaleTransition(
scale: animation,
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: Text(
char,
key: ValueKey<String>(
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<CodeConfirmationScreen> {
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<CodeConfirmationScreen> {
),
child: Icon(
Icons.arrow_forward,
color: _codeController.text.length == 6 ? cs.onPrimaryContainer : cs.onSurfaceVariant,
color: _codeController.text.length == 6
? cs.onPrimaryContainer
: cs.onSurfaceVariant,
),
),
],
+55 -10
View File
@@ -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<LoginScreen> {
late CountryName _selectedCountry;
bool _isPhoneValid = false;
bool _isTOSRead = false;
String? _phoneError;
Timer? _phoneErrorTimer;
@override
void initState() {
@@ -31,6 +34,13 @@ class _LoginScreenState extends State<LoginScreen> {
_checkTOS();
}
@override
void dispose() {
_phoneErrorTimer?.cancel();
_phoneController.dispose();
super.dispose();
}
Future<void> _checkTOS() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
@@ -308,9 +318,19 @@ class _LoginScreenState extends State<LoginScreen> {
);
}
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<LoginScreen> {
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<LoginScreen> {
}
} catch (e) {
if (mounted) {
showCustomNotification(context, 'Ошибка: $e');
_showPhoneError(e.toString());
}
}
},
@@ -719,7 +743,28 @@ class _LoginScreenState extends State<LoginScreen> {
],
),
),
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(
@@ -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<Password2FAScreen> createState() => _Password2FAScreenState();
@@ -43,8 +40,7 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
if (!mounted) return;
// После успешной 2FA делаем login
final loginResult = await accountModule.login();
await accountModule.login();
if (!mounted) return;
@@ -60,9 +56,7 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Неверный пароль: $e')),
);
showCustomNotification(context, 'Неверный пароль: $e');
}
}
@@ -131,7 +125,9 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible ? Icons.visibility_off : Icons.visibility,
_isPasswordVisible
? Icons.visibility_off
: Icons.visibility,
color: cs.onSurfaceVariant,
),
onPressed: () {
+101 -61
View File
@@ -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<ChatListScreen>
late AnimationController _fabController;
final Set<String> _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<CachedChat> _chats = [];
SessionState _sessionState = SessionState.disconnected;
StreamSubscription? _stateSub;
bool _shouldCollapseSearch = false;
bool get _isSelectionMode => _selectedChats.isNotEmpty;
@@ -36,12 +46,21 @@ class _ChatListScreenState extends State<ChatListScreen>
} 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<ChatListScreen>
duration: const Duration(milliseconds: 350),
);
_scrollController.addListener(_onScroll);
_sessionState = api.state;
_stateSub = api.stateStream.listen((state) {
if (mounted) setState(() => _sessionState = state);
});
_loadProfile();
}
Future<void> _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<ChatListScreen>
@override
void dispose() {
_stateSub?.cancel();
_fabController.dispose();
_scrollController.dispose();
super.dispose();
@@ -133,9 +187,9 @@ class _ChatListScreenState extends State<ChatListScreen>
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<ChatListScreen>
curve: Curves.easeOutCubic,
transform: Matrix4.translationValues(
0,
_isSelectionMode ? -100 : 0,
_shouldCollapseSearch ? -100 : 0,
0,
),
child: Column(
@@ -153,7 +207,7 @@ class _ChatListScreenState extends State<ChatListScreen>
20,
12,
20,
4,
2,
),
child: Row(
mainAxisAlignment:
@@ -189,7 +243,9 @@ class _ChatListScreenState extends State<ChatListScreen>
),
),
Text(
'Подключение...',
_sessionState == SessionState.online
? (_profile?.firstName ?? 'Чат')
: 'Подключение...',
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
@@ -266,9 +322,9 @@ class _ChatListScreenState extends State<ChatListScreen>
Padding(
padding: const EdgeInsets.fromLTRB(
20,
4,
2,
20,
12,
8,
),
child: Container(
height: 44,
@@ -316,9 +372,7 @@ class _ChatListScreenState extends State<ChatListScreen>
),
),
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<ChatListScreen>
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<ChatListScreen>
),
),
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<ChatListScreen>
),
),
),
if (!_isSelectionMode) ...[
if (!_isSelectionMode && _currentNavIndex == 0) ...[
if (_fabController.value > 0)
Positioned(
right: 20,
@@ -726,7 +751,22 @@ class _ChatListScreenState extends State<ChatListScreen>
),
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,
+25 -9
View File
@@ -58,10 +58,20 @@ class _ChatScreenState extends State<ChatScreen> {
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<ChatScreen> {
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(
+196 -39
View File
@@ -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<SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends State<SettingsTab> {
ProfileData? _profile;
bool _isPhoneVisible = false;
@override
void initState() {
super.initState();
_loadProfile();
}
Future<void> _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<double> 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;
}
+65 -8
View File
@@ -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<void> _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),
),
);
}
}