я как бы еще не доделал, это такой промежуточный пуш знаете
This commit is contained in:
+49
-61
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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: () {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user