я как бы еще не доделал, это такой промежуточный пуш знаете
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
Не оставляй комментарии в код
|
||||||
|
Старайся писать чистый код
|
||||||
|
Лучше качество чем количество
|
||||||
|
Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно.
|
||||||
|
ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст')
|
||||||
+49
-61
@@ -16,12 +16,7 @@ import 'package:timezone/data/latest_all.dart' as tz;
|
|||||||
import 'package:timezone/timezone.dart' as tz;
|
import 'package:timezone/timezone.dart' as tz;
|
||||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||||
|
|
||||||
enum SessionState {
|
enum SessionState { disconnected, connecting, connected, online }
|
||||||
disconnected,
|
|
||||||
connecting,
|
|
||||||
connected,
|
|
||||||
online
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Клиент API.
|
/// Клиент API.
|
||||||
///
|
///
|
||||||
@@ -34,6 +29,9 @@ class Api {
|
|||||||
|
|
||||||
SessionState _sessionState = SessionState.disconnected;
|
SessionState _sessionState = SessionState.disconnected;
|
||||||
final _stateController = StreamController<SessionState>.broadcast();
|
final _stateController = StreamController<SessionState>.broadcast();
|
||||||
|
Map<dynamic, dynamic>? _userAgent;
|
||||||
|
|
||||||
|
Map<dynamic, dynamic>? get userAgent => _userAgent;
|
||||||
|
|
||||||
Stream<SessionState> get stateStream => _stateController.stream;
|
Stream<SessionState> get stateStream => _stateController.stream;
|
||||||
SessionState get state => _sessionState;
|
SessionState get state => _sessionState;
|
||||||
@@ -98,86 +96,77 @@ class Api {
|
|||||||
_setSessionState(SessionState.disconnected);
|
_setSessionState(SessionState.disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Отправляет хэндшейк (opcode 6).
|
|
||||||
Future<Packet> sendHandshake() async {
|
Future<Packet> sendHandshake() async {
|
||||||
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
|
|
||||||
// Если платформа Linux или Windows, то ставим DESKTOP, если нет, то проверяем на Android или IOS;
|
final deviceType = (Platform.isLinux || Platform.isWindows)
|
||||||
String deviceType = (Platform.isLinux || Platform.isWindows) ? "DESKTOP" : (Platform.isAndroid) ? "ANDROID" : "IOS";
|
? 'DESKTOP'
|
||||||
String osVersion = "";
|
: (Platform.isAndroid)
|
||||||
String deviceName = "Unknown";
|
? 'ANDROID'
|
||||||
String architecture = "arm64";
|
: 'IOS';
|
||||||
|
String osVersion = '';
|
||||||
tz.initializeTimeZones();
|
String deviceName = 'Unknown';
|
||||||
|
String architecture = 'arm64';
|
||||||
final now = DateTime.now();
|
|
||||||
String timezone = "Europe/Moscow";
|
|
||||||
|
|
||||||
tz.initializeTimeZones();
|
tz.initializeTimeZones();
|
||||||
final timeZoneName = await FlutterTimezone.getLocalTimezone();
|
final timeZoneName = await FlutterTimezone.getLocalTimezone();
|
||||||
timezone = timeZoneName.identifier;
|
final timezone = timeZoneName.identifier;
|
||||||
|
|
||||||
// На каждой платформе свое инфо, поэтому делаем такую проверку
|
|
||||||
if (Platform.isLinux) {
|
if (Platform.isLinux) {
|
||||||
LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo;
|
final linuxInfo = await deviceInfo.linuxInfo;
|
||||||
|
|
||||||
osVersion = linuxInfo.name;
|
osVersion = linuxInfo.name;
|
||||||
// Platform.version содержит в себе что-то такое
|
architecture = Platform.version.substring(
|
||||||
// 3.11.1 (stable) (Tue Feb 24 00:03:07 2026 -0800) on "linux_x64"
|
Platform.version.indexOf('_') + 1,
|
||||||
// Поэтому мы находим '_', прибавляем к его индексу 1 и берем символы до length - 1
|
Platform.version.length - 1,
|
||||||
architecture = Platform.version.substring(Platform.version.indexOf('_') + 1, Platform.version.length - 1);
|
);
|
||||||
} else if (Platform.isIOS) {
|
} else if (Platform.isIOS) {
|
||||||
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
|
final iosInfo = await deviceInfo.iosInfo;
|
||||||
|
|
||||||
osVersion = iosInfo.systemVersion;
|
osVersion = iosInfo.systemVersion;
|
||||||
deviceName = iosInfo.utsname.machine;
|
deviceName = iosInfo.utsname.machine;
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
final androidInfo = await deviceInfo.androidInfo;
|
||||||
|
osVersion = 'Android ${androidInfo.version.release}';
|
||||||
osVersion = "Android ${androidInfo.version.release}";
|
deviceName = '${androidInfo.manufacturer} ${androidInfo.model}';
|
||||||
deviceName = "${androidInfo.manufacturer} ${androidInfo.model}";
|
architecture = androidInfo.supportedAbis.first;
|
||||||
architecture = androidInfo.supportedAbis.first;
|
|
||||||
} else if (Platform.isWindows) {
|
} else if (Platform.isWindows) {
|
||||||
WindowsDeviceInfo windowsInfo = await deviceInfo.windowsInfo;
|
final windowsInfo = await deviceInfo.windowsInfo;
|
||||||
|
|
||||||
osVersion = windowsInfo.productName;
|
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>{
|
final payload = <dynamic, dynamic>{
|
||||||
'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000',
|
'mt_instanceid': '550e8400-e29b-41d4-a716-446655440000',
|
||||||
'clientSessionId': 42,
|
'clientSessionId': 42,
|
||||||
'deviceId': 'a1b2c3d4e5f6a7b8',
|
'deviceId': 'a1b2c3d4e5f6a7b8',
|
||||||
'userAgent': {
|
'userAgent': _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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
print(payload);
|
|
||||||
print(Platform.version);
|
|
||||||
return sendRequest(Opcode.sessionInit, payload);
|
return sendRequest(Opcode.sessionInit, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Отправляет запрос и ждёт ответ от сервера.
|
/// Отправляет запрос и ждёт ответ от сервера.
|
||||||
Future<Packet> sendRequest(
|
Future<Packet> sendRequest(int opcode, Map<dynamic, dynamic> payload) {
|
||||||
int opcode,
|
|
||||||
Map<dynamic, dynamic> payload,
|
|
||||||
) {
|
|
||||||
final seq = _sender.send(_connection, opcode, payload);
|
final seq = _sender.send(_connection, opcode, payload);
|
||||||
return _dispatcher.registerPending(seq).timeout(
|
return _dispatcher
|
||||||
|
.registerPending(seq)
|
||||||
|
.timeout(
|
||||||
ServerConfig.requestTimeout,
|
ServerConfig.requestTimeout,
|
||||||
onTimeout: () =>
|
onTimeout: () =>
|
||||||
throw TimeoutException('${Opcode.name(opcode)} таймаут'),
|
throw TimeoutException('${Opcode.name(opcode)} таймаут'),
|
||||||
@@ -203,7 +192,6 @@ class Api {
|
|||||||
|
|
||||||
// Внутрянка
|
// Внутрянка
|
||||||
|
|
||||||
|
|
||||||
void _setSessionState(SessionState state) {
|
void _setSessionState(SessionState state) {
|
||||||
if (_sessionState == state) return;
|
if (_sessionState == state) return;
|
||||||
_sessionState = state;
|
_sessionState = state;
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import '../../core/storage/app_database.dart';
|
|||||||
import '../../core/storage/token_storage.dart';
|
import '../../core/storage/token_storage.dart';
|
||||||
import '../../core/utils/logger.dart';
|
import '../../core/utils/logger.dart';
|
||||||
import 'chats.dart';
|
import 'chats.dart';
|
||||||
|
import 'contacts.dart';
|
||||||
|
|
||||||
|
class ServerException implements Exception {
|
||||||
|
final String message;
|
||||||
|
const ServerException(this.message);
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
enum AuthRequestType {
|
enum AuthRequestType {
|
||||||
startAuth('START_AUTH'),
|
startAuth('START_AUTH'),
|
||||||
@@ -115,11 +123,13 @@ class LoginResult {
|
|||||||
final ProfileData profile;
|
final ProfileData profile;
|
||||||
final String? updatedToken;
|
final String? updatedToken;
|
||||||
final int serverTime;
|
final int serverTime;
|
||||||
|
final Map<dynamic, dynamic> raw;
|
||||||
|
|
||||||
const LoginResult({
|
const LoginResult({
|
||||||
required this.profile,
|
required this.profile,
|
||||||
required this.updatedToken,
|
required this.updatedToken,
|
||||||
required this.serverTime,
|
required this.serverTime,
|
||||||
|
required this.raw,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,14 +141,12 @@ class AccountModule {
|
|||||||
Future<RequestCodeResult> requestCode(
|
Future<RequestCodeResult> requestCode(
|
||||||
String phone, {
|
String phone, {
|
||||||
String language = 'ru',
|
String language = 'ru',
|
||||||
}) =>
|
}) => _requestCodeInternal(phone, AuthRequestType.startAuth, language);
|
||||||
_requestCodeInternal(phone, AuthRequestType.startAuth, language);
|
|
||||||
|
|
||||||
Future<RequestCodeResult> resendCode(
|
Future<RequestCodeResult> resendCode(
|
||||||
String phone, {
|
String phone, {
|
||||||
String language = 'ru',
|
String language = 'ru',
|
||||||
}) =>
|
}) => _requestCodeInternal(phone, AuthRequestType.resend, language);
|
||||||
_requestCodeInternal(phone, AuthRequestType.resend, language);
|
|
||||||
|
|
||||||
Future<VerifyCodeResult> verifyCode(String code, String token) async {
|
Future<VerifyCodeResult> verifyCode(String code, String token) async {
|
||||||
_ensureOnline();
|
_ensureOnline();
|
||||||
@@ -157,7 +165,9 @@ class AccountModule {
|
|||||||
|
|
||||||
final data = packet.payload;
|
final data = packet.payload;
|
||||||
if (data is! Map) {
|
if (data is! Map) {
|
||||||
throw Exception('verifyCode: неожиданный тип payload: ${data.runtimeType}');
|
throw Exception(
|
||||||
|
'verifyCode: неожиданный тип payload: ${data.runtimeType}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = VerifyCodeResult(payload: data.cast<dynamic, dynamic>());
|
final result = VerifyCodeResult(payload: data.cast<dynamic, dynamic>());
|
||||||
@@ -181,7 +191,8 @@ class AccountModule {
|
|||||||
}) async {
|
}) async {
|
||||||
_ensureOnline();
|
_ensureOnline();
|
||||||
|
|
||||||
final resolvedAccountId = accountId ?? await TokenStorage.getActiveAccountId();
|
final resolvedAccountId =
|
||||||
|
accountId ?? await TokenStorage.getActiveAccountId();
|
||||||
if (resolvedAccountId == null) {
|
if (resolvedAccountId == null) {
|
||||||
throw StateError('login: нет активного аккаунта');
|
throw StateError('login: нет активного аккаунта');
|
||||||
}
|
}
|
||||||
@@ -191,13 +202,7 @@ class AccountModule {
|
|||||||
throw StateError('login: нет токена для аккаунта $resolvedAccountId');
|
throw StateError('login: нет токена для аккаунта $resolvedAccountId');
|
||||||
}
|
}
|
||||||
|
|
||||||
final resolvedSyncParams =
|
final requestPayload = _buildLoginPayload(authToken, syncParams);
|
||||||
syncParams ?? await LoginSyncParams.fromDatabase(resolvedAccountId);
|
|
||||||
|
|
||||||
final requestPayload = _buildLoginPayload(authToken, resolvedSyncParams);
|
|
||||||
|
|
||||||
logger.i('LOGIN opcode=${Opcode.login} '
|
|
||||||
'account=$resolvedAccountId warm=${resolvedSyncParams != null}');
|
|
||||||
|
|
||||||
final packet = await _api.sendRequest(Opcode.login, requestPayload);
|
final packet = await _api.sendRequest(Opcode.login, requestPayload);
|
||||||
|
|
||||||
@@ -262,7 +267,9 @@ class AccountModule {
|
|||||||
|
|
||||||
final data = packet.payload;
|
final data = packet.payload;
|
||||||
if (data is! Map) {
|
if (data is! Map) {
|
||||||
throw Exception('checkPassword: неожиданный тип payload: ${data.runtimeType}');
|
throw Exception(
|
||||||
|
'checkPassword: неожиданный тип payload: ${data.runtimeType}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data['error'] != null) {
|
if (data['error'] != null) {
|
||||||
@@ -310,7 +317,8 @@ class AccountModule {
|
|||||||
) {
|
) {
|
||||||
final payload = <dynamic, dynamic>{
|
final payload = <dynamic, dynamic>{
|
||||||
'token': token,
|
'token': token,
|
||||||
'interactive': true
|
'interactive': true,
|
||||||
|
if (_api.userAgent != null) 'userAgent': _api.userAgent,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (sync != null) {
|
if (sync != null) {
|
||||||
@@ -342,7 +350,6 @@ class AccountModule {
|
|||||||
final updatedToken = data['token'] as String?;
|
final updatedToken = data['token'] as String?;
|
||||||
if (updatedToken != null) {
|
if (updatedToken != null) {
|
||||||
await TokenStorage.saveToken(updatedToken, accountId);
|
await TokenStorage.saveToken(updatedToken, accountId);
|
||||||
logger.i('Обновлённый токен аккаунта $accountId сохранён');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final profileMap = data['profile'];
|
final profileMap = data['profile'];
|
||||||
@@ -356,15 +363,16 @@ class AccountModule {
|
|||||||
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||||
await AppDatabase.saveProfile(profile);
|
await AppDatabase.saveProfile(profile);
|
||||||
await AppDatabase.setActiveAccount(profile.id);
|
await AppDatabase.setActiveAccount(profile.id);
|
||||||
logger.i('Профиль сохранён: id=${profile.id}, name=${profile.firstName}');
|
|
||||||
|
|
||||||
await _saveSyncState(data, serverTime, profile.id);
|
await _saveSyncState(data, serverTime, profile.id);
|
||||||
|
await ContactsModule.syncFromLoginPayload(data, profile.id);
|
||||||
await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id);
|
await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id);
|
||||||
|
|
||||||
return LoginResult(
|
return LoginResult(
|
||||||
profile: profile,
|
profile: profile,
|
||||||
updatedToken: updatedToken,
|
updatedToken: updatedToken,
|
||||||
serverTime: serverTime,
|
serverTime: serverTime,
|
||||||
|
raw: data,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +423,9 @@ class AccountModule {
|
|||||||
|
|
||||||
final data = packet.payload;
|
final data = packet.payload;
|
||||||
if (data is! Map) {
|
if (data is! Map) {
|
||||||
throw Exception('requestCode: неожиданный тип payload: ${data.runtimeType}');
|
throw Exception(
|
||||||
|
'requestCode: неожиданный тип payload: ${data.runtimeType}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final token = data['token'];
|
final token = data['token'];
|
||||||
@@ -439,8 +449,8 @@ class AccountModule {
|
|||||||
if (packet.isError) {
|
if (packet.isError) {
|
||||||
final errMsg = packet.payload is Map
|
final errMsg = packet.payload is Map
|
||||||
? (packet.payload as Map)['message'] ?? packet.payload.toString()
|
? (packet.payload as Map)['message'] ?? packet.payload.toString()
|
||||||
: packet.payload?.toString() ?? 'unknown error';
|
: packet.payload?.toString() ?? 'Неизвестная ошибка';
|
||||||
throw Exception('$method: ошибка от сервера — $errMsg');
|
throw ServerException(errMsg.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ class CachedChat {
|
|||||||
final int unreadCount;
|
final int unreadCount;
|
||||||
final int lastEventTime;
|
final int lastEventTime;
|
||||||
final int cachedAt;
|
final int cachedAt;
|
||||||
|
final int? favIndex;
|
||||||
|
final int dontDisturbUntil;
|
||||||
|
final bool isOnline;
|
||||||
|
final int seenTime;
|
||||||
|
|
||||||
const CachedChat({
|
const CachedChat({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -27,37 +31,49 @@ class CachedChat {
|
|||||||
required this.unreadCount,
|
required this.unreadCount,
|
||||||
required this.lastEventTime,
|
required this.lastEventTime,
|
||||||
required this.cachedAt,
|
required this.cachedAt,
|
||||||
|
this.favIndex,
|
||||||
|
required this.dontDisturbUntil,
|
||||||
|
required this.isOnline,
|
||||||
|
required this.seenTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CachedChat.fromDbRow(Map<String, dynamic> row) => CachedChat(
|
factory CachedChat.fromDbRow(Map<String, dynamic> row) => CachedChat(
|
||||||
id: row['id'] as int,
|
id: row['id'] as int,
|
||||||
accountId: row['account_id'] as int,
|
accountId: row['account_id'] as int,
|
||||||
type: row['type'] as String,
|
type: row['type'] as String,
|
||||||
title: row['title'] as String?,
|
title: row['title'] as String?,
|
||||||
iconUrl: row['icon_url'] as String?,
|
iconUrl: row['icon_url'] as String?,
|
||||||
lastMsgId: row['last_msg_id'] as int?,
|
lastMsgId: row['last_msg_id'] as int?,
|
||||||
lastMsgTime: row['last_msg_time'] as int?,
|
lastMsgTime: row['last_msg_time'] as int?,
|
||||||
lastMsgText: row['last_msg_text'] as String?,
|
lastMsgText: row['last_msg_text'] as String?,
|
||||||
lastMsgSenderId: row['last_msg_sender'] as int?,
|
lastMsgSenderId: row['last_msg_sender'] as int?,
|
||||||
unreadCount: row['unread_count'] as int,
|
unreadCount: row['unread_count'] as int,
|
||||||
lastEventTime: row['last_event_time'] as int,
|
lastEventTime: row['last_event_time'] as int,
|
||||||
cachedAt: row['cached_at'] 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() => {
|
Map<String, dynamic> toDbRow() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'account_id': accountId,
|
'account_id': accountId,
|
||||||
'type': type,
|
'type': type,
|
||||||
'title': title,
|
'title': title,
|
||||||
'icon_url': iconUrl,
|
'icon_url': iconUrl,
|
||||||
'last_msg_id': lastMsgId,
|
'last_msg_id': lastMsgId,
|
||||||
'last_msg_time': lastMsgTime,
|
'last_msg_time': lastMsgTime,
|
||||||
'last_msg_text': lastMsgText,
|
'last_msg_text': lastMsgText,
|
||||||
'last_msg_sender': lastMsgSenderId,
|
'last_msg_sender': lastMsgSenderId,
|
||||||
'unread_count': unreadCount,
|
'unread_count': unreadCount,
|
||||||
'last_event_time': lastEventTime,
|
'last_event_time': lastEventTime,
|
||||||
'cached_at': cachedAt,
|
'cached_at': cachedAt,
|
||||||
};
|
'fav_index': favIndex,
|
||||||
|
'dont_disturb_until': dontDisturbUntil,
|
||||||
|
'is_online': isOnline ? 1 : 0,
|
||||||
|
'seen_time': seenTime,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatsModule {
|
class ChatsModule {
|
||||||
@@ -75,23 +91,35 @@ class ChatsModule {
|
|||||||
if (chats is! List || chats.isEmpty) return;
|
if (chats is! List || chats.isEmpty) return;
|
||||||
|
|
||||||
final contactsMap = _buildContactsMap(data['contacts']);
|
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 cachedAt = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
final existingRows = await AppDatabase.loadChats(accountId);
|
final existingRows = await AppDatabase.loadChats(accountId);
|
||||||
final existing = {
|
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
|
final rows = chats
|
||||||
.whereType<Map>()
|
.whereType<Map>()
|
||||||
.map((c) => _parseChat(
|
.map(
|
||||||
c.cast<dynamic, dynamic>(),
|
(c) => _parseChat(
|
||||||
accountId,
|
c.cast<dynamic, dynamic>(),
|
||||||
currentUserId,
|
accountId,
|
||||||
contactsMap,
|
currentUserId,
|
||||||
existing,
|
contactsMap,
|
||||||
cachedAt,
|
chatsConfig,
|
||||||
))
|
presenceMap,
|
||||||
|
existing,
|
||||||
|
cachedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
.whereType<CachedChat>()
|
.whereType<CachedChat>()
|
||||||
.map((c) => c.toDbRow())
|
.map((c) => c.toDbRow())
|
||||||
.toList();
|
.toList();
|
||||||
@@ -109,7 +137,7 @@ class ChatsModule {
|
|||||||
static Future<void> clearCache(int accountId) =>
|
static Future<void> clearCache(int accountId) =>
|
||||||
AppDatabase.clearChatsCache(accountId);
|
AppDatabase.clearChatsCache(accountId);
|
||||||
|
|
||||||
// internal
|
// internal
|
||||||
|
|
||||||
static Map<int, Map<dynamic, dynamic>> _buildContactsMap(dynamic contacts) {
|
static Map<int, Map<dynamic, dynamic>> _buildContactsMap(dynamic contacts) {
|
||||||
if (contacts is! List) return {};
|
if (contacts is! List) return {};
|
||||||
@@ -126,6 +154,8 @@ class ChatsModule {
|
|||||||
int accountId,
|
int accountId,
|
||||||
int currentUserId,
|
int currentUserId,
|
||||||
Map<int, Map<dynamic, dynamic>> contactsMap,
|
Map<int, Map<dynamic, dynamic>> contactsMap,
|
||||||
|
Map<dynamic, dynamic> chatsConfig,
|
||||||
|
Map<dynamic, dynamic> presenceMap,
|
||||||
Map<int, CachedChat> existing,
|
Map<int, CachedChat> existing,
|
||||||
int cachedAt,
|
int cachedAt,
|
||||||
) {
|
) {
|
||||||
@@ -133,19 +163,19 @@ class ChatsModule {
|
|||||||
if (id is! int) return null;
|
if (id is! int) return null;
|
||||||
|
|
||||||
final type = (chat['type'] as String?) ?? 'DIALOG';
|
final type = (chat['type'] as String?) ?? 'DIALOG';
|
||||||
|
int? otherId;
|
||||||
|
|
||||||
String? title;
|
String? title;
|
||||||
String? iconUrl;
|
String? iconUrl;
|
||||||
|
|
||||||
if (type == 'DIALOG') {
|
if (type == 'DIALOG') {
|
||||||
final otherId = _otherParticipantId(chat['participants'], currentUserId);
|
otherId = _otherParticipantId(chat['participants'], currentUserId);
|
||||||
final contact = otherId != null ? contactsMap[otherId] : null;
|
final contact = otherId != null ? contactsMap[otherId] : null;
|
||||||
|
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
title = _nameFromContact(contact);
|
title = _nameFromContact(contact);
|
||||||
iconUrl = contact['baseUrl'] as String?;
|
iconUrl = contact['baseUrl'] as String?;
|
||||||
} else {
|
} else {
|
||||||
// Warm start: контакты не пришли — берём из кэша
|
|
||||||
title = existing[id]?.title;
|
title = existing[id]?.title;
|
||||||
iconUrl = existing[id]?.iconUrl;
|
iconUrl = existing[id]?.iconUrl;
|
||||||
}
|
}
|
||||||
@@ -167,6 +197,24 @@ class ChatsModule {
|
|||||||
lastMsgSenderId = lastMsg['sender'] as int?;
|
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(
|
return CachedChat(
|
||||||
id: id,
|
id: id,
|
||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
@@ -180,6 +228,10 @@ class ChatsModule {
|
|||||||
unreadCount: (chat['newMessages'] as int?) ?? 0,
|
unreadCount: (chat['newMessages'] as int?) ?? 0,
|
||||||
lastEventTime: (chat['lastEventTime'] as int?) ?? 0,
|
lastEventTime: (chat['lastEventTime'] as int?) ?? 0,
|
||||||
cachedAt: cachedAt,
|
cachedAt: cachedAt,
|
||||||
|
favIndex: favIndex,
|
||||||
|
dontDisturbUntil: dontDisturbUntil,
|
||||||
|
isOnline: isOnline,
|
||||||
|
seenTime: seenTime,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,10 +247,12 @@ class ChatsModule {
|
|||||||
static String? _nameFromContact(Map<dynamic, dynamic> contact) {
|
static String? _nameFromContact(Map<dynamic, dynamic> contact) {
|
||||||
final names = contact['names'];
|
final names = contact['names'];
|
||||||
if (names is! List || names.isEmpty) return null;
|
if (names is! List || names.isEmpty) return null;
|
||||||
final name = names.firstWhere(
|
final name =
|
||||||
(n) => n is Map && n['type'] == 'ONEME',
|
names.firstWhere(
|
||||||
orElse: () => names.first,
|
(n) => n is Map && n['type'] == 'ONEME',
|
||||||
) as Map;
|
orElse: () => names.first,
|
||||||
|
)
|
||||||
|
as Map;
|
||||||
return name['name'] as String?;
|
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;
|
String? lastName;
|
||||||
|
|
||||||
if (names is List && names.isNotEmpty) {
|
if (names is List && names.isNotEmpty) {
|
||||||
final name = names.firstWhere(
|
final name =
|
||||||
(n) => n is Map && n['type'] == 'ONEME',
|
names.firstWhere(
|
||||||
orElse: () => names.first,
|
(n) => n is Map && n['type'] == 'ONEME',
|
||||||
) as Map;
|
orElse: () => names.first,
|
||||||
|
)
|
||||||
|
as Map;
|
||||||
firstName = (name['firstName'] as String?) ?? '';
|
firstName = (name['firstName'] as String?) ?? '';
|
||||||
lastName = name['lastName'] as String?;
|
lastName = name['lastName'] as String?;
|
||||||
}
|
}
|
||||||
@@ -73,17 +75,17 @@ class ProfileData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toDbRow() => {
|
Map<String, dynamic> toDbRow() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'first_name': firstName,
|
'first_name': firstName,
|
||||||
'last_name': lastName,
|
'last_name': lastName,
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
'photo_id': photoId,
|
'photo_id': photoId,
|
||||||
'base_url': baseUrl,
|
'base_url': baseUrl,
|
||||||
'base_raw_url': baseRawUrl,
|
'base_raw_url': baseRawUrl,
|
||||||
'country': country,
|
'country': country,
|
||||||
'account_status': accountStatus,
|
'account_status': accountStatus,
|
||||||
'update_time': updateTime,
|
'update_time': updateTime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class SyncKey {
|
abstract class SyncKey {
|
||||||
@@ -118,7 +120,7 @@ class AppDatabase {
|
|||||||
final dbPath = await getDatabasesPath();
|
final dbPath = await getDatabasesPath();
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
join(dbPath, 'komet.db'),
|
join(dbPath, 'komet.db'),
|
||||||
version: 3,
|
version: 5,
|
||||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||||
onCreate: (db, _) => _createTables(db),
|
onCreate: (db, _) => _createTables(db),
|
||||||
onUpgrade: (db, oldVersion, newVersion) async {
|
onUpgrade: (db, oldVersion, newVersion) async {
|
||||||
@@ -132,6 +134,13 @@ class AppDatabase {
|
|||||||
if (oldVersion < 3) {
|
if (oldVersion < 3) {
|
||||||
await db.execute(_chatsCacheSchema);
|
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(_syncStateSchema);
|
||||||
await db.execute(_chatsCacheSchema);
|
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 = '''
|
static const _syncStateSchema = '''
|
||||||
CREATE TABLE sync_state (
|
CREATE TABLE sync_state (
|
||||||
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||||
@@ -179,6 +203,10 @@ class AppDatabase {
|
|||||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||||
last_event_time INTEGER NOT NULL DEFAULT 0,
|
last_event_time INTEGER NOT NULL DEFAULT 0,
|
||||||
cached_at INTEGER NOT NULL,
|
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)
|
PRIMARY KEY (id, account_id)
|
||||||
)
|
)
|
||||||
''';
|
''';
|
||||||
@@ -212,11 +240,7 @@ class AppDatabase {
|
|||||||
|
|
||||||
static Future<ProfileData?> loadActiveProfile() async {
|
static Future<ProfileData?> loadActiveProfile() async {
|
||||||
final db = await _instance;
|
final db = await _instance;
|
||||||
final rows = await db.query(
|
final rows = await db.query('profile', where: 'is_active = 1', limit: 1);
|
||||||
'profile',
|
|
||||||
where: 'is_active = 1',
|
|
||||||
limit: 1,
|
|
||||||
);
|
|
||||||
if (rows.isEmpty) return null;
|
if (rows.isEmpty) return null;
|
||||||
return ProfileData.fromDbRow(rows.first);
|
return ProfileData.fromDbRow(rows.first);
|
||||||
}
|
}
|
||||||
@@ -245,11 +269,11 @@ class AppDatabase {
|
|||||||
String value,
|
String value,
|
||||||
) async {
|
) async {
|
||||||
final db = await _instance;
|
final db = await _instance;
|
||||||
await db.insert(
|
await db.insert('sync_state', {
|
||||||
'sync_state',
|
'account_id': accountId,
|
||||||
{'account_id': accountId, 'key': key, 'value': value},
|
'key': key,
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
'value': value,
|
||||||
);
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String?> getSyncValue(int accountId, String key) async {
|
static Future<String?> getSyncValue(int accountId, String key) async {
|
||||||
@@ -281,13 +305,17 @@ class AppDatabase {
|
|||||||
_db = null;
|
_db = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chats cache
|
// Chats cache
|
||||||
|
|
||||||
static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
|
static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
|
||||||
final db = await _instance;
|
final db = await _instance;
|
||||||
final batch = db.batch();
|
final batch = db.batch();
|
||||||
for (final row in rows) {
|
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);
|
await batch.commit(noResult: true);
|
||||||
}
|
}
|
||||||
@@ -304,6 +332,32 @@ class AppDatabase {
|
|||||||
|
|
||||||
static Future<void> clearChatsCache(int accountId) async {
|
static Future<void> clearChatsCache(int accountId) async {
|
||||||
final db = await _instance;
|
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 {
|
class TokenStorage {
|
||||||
static const _tokenPrefix = 'auth_token_';
|
static const _tokenPrefix = 'auth_token_';
|
||||||
static const _activeAccountKey = 'active_account_id';
|
static const _activeAccountKey = 'active_account_id';
|
||||||
|
|
||||||
static const _storage = FlutterSecureStorage(
|
static Future<void> saveToken(String token, int accountId) async {
|
||||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
final prefs = await SharedPreferences.getInstance();
|
||||||
);
|
await prefs.setString('$_tokenPrefix$accountId', token);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> saveToken(String token, int accountId) =>
|
static Future<String?> readToken(int accountId) async {
|
||||||
_storage.write(key: '$_tokenPrefix$accountId', value: token);
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString('$_tokenPrefix$accountId');
|
||||||
|
}
|
||||||
|
|
||||||
static Future<String?> readToken(int accountId) =>
|
static Future<void> deleteToken(int accountId) async {
|
||||||
_storage.read(key: '$_tokenPrefix$accountId');
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove('$_tokenPrefix$accountId');
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> deleteToken(int accountId) =>
|
static Future<void> setActiveAccount(int accountId) async {
|
||||||
_storage.delete(key: '$_tokenPrefix$accountId');
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_activeAccountKey, accountId.toString());
|
||||||
static Future<void> setActiveAccount(int accountId) =>
|
}
|
||||||
_storage.write(key: _activeAccountKey, value: accountId.toString());
|
|
||||||
|
|
||||||
static Future<int?> getActiveAccountId() async {
|
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;
|
return val != null ? int.tryParse(val) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,12 +36,12 @@ class TokenStorage {
|
|||||||
return readToken(id);
|
return readToken(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удаляет токен аккаунта и, если он был активным, сбрасывает активный аккаунт.
|
|
||||||
static Future<void> deleteAccount(int accountId) async {
|
static Future<void> deleteAccount(int accountId) async {
|
||||||
await deleteToken(accountId);
|
await deleteToken(accountId);
|
||||||
final activeId = await getActiveAccountId();
|
final activeId = await getActiveAccountId();
|
||||||
if (activeId == accountId) {
|
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 ||
|
if (packet.cmd == CmdType.ok ||
|
||||||
packet.cmd == CmdType.error ||
|
packet.cmd == CmdType.error ||
|
||||||
packet.cmd == CmdType.notFound) {
|
packet.cmd == CmdType.notFound) {
|
||||||
final status = packet.isOk ? 'OK' : packet.isError ? 'ERR' : 'NOT_FOUND';
|
logger.i(
|
||||||
logger.i('<= [$tag] seq=${packet.seq} $status\n payload: ${packet.payload}');
|
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}',
|
||||||
|
);
|
||||||
|
|
||||||
final completer = _pendingRequests.remove(packet.seq);
|
final completer = _pendingRequests.remove(packet.seq);
|
||||||
_requestTimestamps.remove(packet.seq);
|
_requestTimestamps.remove(packet.seq);
|
||||||
@@ -72,7 +73,9 @@ class PacketDispatcher {
|
|||||||
completer.complete(packet);
|
completer.complete(packet);
|
||||||
}
|
}
|
||||||
} else if (packet.isPush) {
|
} 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);
|
_pushHandlers[packet.opcode]?.call(packet);
|
||||||
_pushController.add(packet);
|
_pushController.add(packet);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import '../protocol/packet.dart';
|
import '../protocol/packet.dart';
|
||||||
import '../protocol/opcode_map.dart';
|
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
import 'connection.dart';
|
import 'connection.dart';
|
||||||
|
|
||||||
@@ -19,7 +18,9 @@ class PacketSender {
|
|||||||
final seq = _nextSeq();
|
final seq = _nextSeq();
|
||||||
final data = packPacket(opcode, payload, seq: seq);
|
final data = packPacket(opcode, payload, seq: seq);
|
||||||
connection.write(data);
|
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;
|
return seq;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import '../chats/chat_list_screen.dart';
|
import '../chats/chat_list_screen.dart';
|
||||||
import 'password_2fa_screen.dart';
|
import 'password_2fa_screen.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
|
import '../../widgets/custom_notification.dart';
|
||||||
|
|
||||||
class CodeConfirmationScreen extends StatefulWidget {
|
class CodeConfirmationScreen extends StatefulWidget {
|
||||||
final String phoneNumber;
|
final String phoneNumber;
|
||||||
final String token;
|
final String token;
|
||||||
|
|
||||||
const CodeConfirmationScreen({
|
const CodeConfirmationScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.phoneNumber,
|
required this.phoneNumber,
|
||||||
required this.token,
|
required this.token,
|
||||||
});
|
});
|
||||||
@@ -20,11 +21,17 @@ class CodeConfirmationScreen extends StatefulWidget {
|
|||||||
State<CodeConfirmationScreen> createState() => _CodeConfirmationScreenState();
|
State<CodeConfirmationScreen> createState() => _CodeConfirmationScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
class _CodeConfirmationScreenState extends State<CodeConfirmationScreen>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
final TextEditingController _codeController = TextEditingController();
|
final TextEditingController _codeController = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
int _timerSeconds = 30;
|
int _timerSeconds = 30;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
Timer? _errorTimer;
|
||||||
|
|
||||||
|
String? _errorMessage;
|
||||||
|
late AnimationController _shakeController;
|
||||||
|
late Animation<double> _shakeAnimation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -33,11 +40,26 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_focusNode.requestFocus();
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
_errorTimer?.cancel();
|
||||||
|
_shakeController.dispose();
|
||||||
_codeController.dispose();
|
_codeController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
super.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() {
|
void _resendCode() {
|
||||||
if (_timerSeconds == 0) {
|
if (_timerSeconds == 0) {
|
||||||
_startTimer();
|
_startTimer();
|
||||||
// TODO: вызвать accountModule.resendCode
|
|
||||||
print('Resending code to ${widget.phoneNumber}');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,29 +107,24 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
|||||||
|
|
||||||
if (result.requiresPassword) {
|
if (result.requiresPassword) {
|
||||||
final trackId = result.challengeTrackId;
|
final trackId = result.challengeTrackId;
|
||||||
|
|
||||||
if (trackId == null) {
|
if (trackId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showCustomNotification(context, 'Ошибка: отсутствуют данные для 2FA');
|
||||||
SnackBar(content: Text('Ошибка: отсутствуют данные для 2FA')),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => Password2FAScreen(
|
builder: (context) =>
|
||||||
trackId: trackId,
|
Password2FAScreen(trackId: trackId, hint: result.challengeHint),
|
||||||
hint: result.challengeHint,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если 2FA не требуется, делаем login
|
await accountModule.login();
|
||||||
final loginResult = await accountModule.login();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
Navigator.pushAndRemoveUntil(
|
Navigator.pushAndRemoveUntil(
|
||||||
@@ -110,19 +134,15 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_showError(e.toString());
|
||||||
SnackBar(content: Text('Ошибка: $e')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToChats() {
|
|
||||||
_verifyCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final hasError = _errorMessage != null;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: cs.surface,
|
backgroundColor: cs.surface,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -159,92 +179,155 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Stack(
|
AnimatedBuilder(
|
||||||
children: [
|
animation: _shakeAnimation,
|
||||||
Opacity(
|
builder: (context, child) => Transform.translate(
|
||||||
opacity: 0,
|
offset: Offset(_shakeAnimation.value, 0),
|
||||||
child: SizedBox(
|
child: child,
|
||||||
height: 0,
|
),
|
||||||
width: 0,
|
child: Stack(
|
||||||
child: TextField(
|
children: [
|
||||||
controller: _codeController,
|
Opacity(
|
||||||
focusNode: _focusNode,
|
opacity: 0,
|
||||||
keyboardType: TextInputType.number,
|
child: SizedBox(
|
||||||
autofillHints: const [AutofillHints.oneTimeCode],
|
height: 0,
|
||||||
inputFormatters: [
|
width: 0,
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
child: TextField(
|
||||||
LengthLimitingTextInputFormatter(6),
|
controller: _codeController,
|
||||||
],
|
focusNode: _focusNode,
|
||||||
onChanged: (value) {
|
keyboardType: TextInputType.number,
|
||||||
setState(() {});
|
autofillHints: const [AutofillHints.oneTimeCode],
|
||||||
if (value.length == 6) {
|
inputFormatters: [
|
||||||
_navigateToChats();
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
}
|
LengthLimitingTextInputFormatter(6),
|
||||||
},
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (hasError) setState(() => _errorMessage = null);
|
||||||
|
setState(() {});
|
||||||
|
if (value.length == 6) _verifyCode();
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
GestureDetector(
|
||||||
GestureDetector(
|
onTap: () => _focusNode.requestFocus(),
|
||||||
onTap: () => _focusNode.requestFocus(),
|
child: FittedBox(
|
||||||
child: FittedBox(
|
child: Row(
|
||||||
child: Row(
|
children: List.generate(6, (index) {
|
||||||
children: List.generate(6, (index) {
|
final isFocused =
|
||||||
bool isFocused = _codeController.text.length == index && _focusNode.hasFocus;
|
_codeController.text.length == index &&
|
||||||
bool hasValue = _codeController.text.length > index;
|
_focusNode.hasFocus;
|
||||||
String char = hasValue ? _codeController.text[index] : '';
|
final hasValue =
|
||||||
|
_codeController.text.length > index;
|
||||||
|
final char = hasValue
|
||||||
|
? _codeController.text[index]
|
||||||
|
: '';
|
||||||
|
|
||||||
return Container(
|
Color borderColor;
|
||||||
width: 44,
|
if (hasError && hasValue) {
|
||||||
height: 54,
|
borderColor = cs.error;
|
||||||
margin: EdgeInsets.only(right: index == 5 ? 0 : 10),
|
} else if (isFocused) {
|
||||||
decoration: BoxDecoration(
|
borderColor = cs.primary;
|
||||||
color: cs.surfaceContainerHigh,
|
} else if (hasValue) {
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderColor = cs.outlineVariant;
|
||||||
border: Border.all(
|
} else {
|
||||||
color: isFocused
|
borderColor = Colors.transparent;
|
||||||
? cs.primary
|
}
|
||||||
: (hasValue ? cs.outlineVariant : Colors.transparent),
|
|
||||||
width: 1.5,
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: 44,
|
||||||
|
height: 54,
|
||||||
|
margin: EdgeInsets.only(
|
||||||
|
right: index == 5 ? 0 : 10,
|
||||||
),
|
),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
alignment: Alignment.center,
|
color: hasError && hasValue
|
||||||
child: AnimatedSwitcher(
|
? cs.error.withValues(alpha: 0.1)
|
||||||
duration: const Duration(milliseconds: 100),
|
: cs.surfaceContainerHigh,
|
||||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
borderRadius: BorderRadius.circular(8),
|
||||||
return ScaleTransition(
|
border: Border.all(
|
||||||
scale: animation,
|
color: borderColor,
|
||||||
child: FadeTransition(opacity: animation, child: child),
|
width: 1.5,
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
char,
|
|
||||||
key: ValueKey<String>(char + index.toString()),
|
|
||||||
style: TextStyle(
|
|
||||||
color: cs.onSurface,
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
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(
|
GestureDetector(
|
||||||
onTap: _resendCode,
|
onTap: _resendCode,
|
||||||
child: Text(
|
child: AnimatedDefaultTextStyle(
|
||||||
_timerSeconds > 0
|
duration: const Duration(milliseconds: 200),
|
||||||
? 'Отправить повторно через $_timerSeconds сек.'
|
|
||||||
: 'Отправить код по SMS',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.tertiary,
|
color: _timerSeconds > 0 ? cs.outline : cs.tertiary,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
|
child: Text(
|
||||||
|
_timerSeconds > 0
|
||||||
|
? 'Отправить повторно через $_timerSeconds сек.'
|
||||||
|
: 'Отправить код по SMS',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -253,9 +336,7 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
|||||||
children: [
|
children: [
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_codeController.text.length == 6) {
|
if (_codeController.text.length == 6) _verifyCode();
|
||||||
_navigateToChats();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
backgroundColor: _codeController.text.length == 6
|
backgroundColor: _codeController.text.length == 6
|
||||||
? cs.primaryContainer
|
? cs.primaryContainer
|
||||||
@@ -266,7 +347,9 @@ class _CodeConfirmationScreenState extends State<CodeConfirmationScreen> {
|
|||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.arrow_forward,
|
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/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -23,6 +24,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
late CountryName _selectedCountry;
|
late CountryName _selectedCountry;
|
||||||
bool _isPhoneValid = false;
|
bool _isPhoneValid = false;
|
||||||
bool _isTOSRead = false;
|
bool _isTOSRead = false;
|
||||||
|
String? _phoneError;
|
||||||
|
Timer? _phoneErrorTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -31,6 +34,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
_checkTOS();
|
_checkTOS();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_phoneErrorTimer?.cancel();
|
||||||
|
_phoneController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkTOS() async {
|
Future<void> _checkTOS() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (mounted) {
|
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) {
|
void _showPhoneConfirmationDialog(String formattedPhone) {
|
||||||
|
final screenContext = context;
|
||||||
|
|
||||||
showGeneralDialog(
|
showGeneralDialog(
|
||||||
context: context,
|
context: screenContext,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
barrierLabel: '',
|
barrierLabel: '',
|
||||||
barrierColor: Colors.black54,
|
barrierColor: Colors.black54,
|
||||||
@@ -374,18 +394,22 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
||||||
final fullPhone = '${_selectedCountry.phoneCode}${_phoneController.text}';
|
final fullPhone =
|
||||||
|
'${_selectedCountry.phoneCode}${_phoneController.text}';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await accountModule.requestCode(fullPhone);
|
final result = await accountModule.requestCode(
|
||||||
|
fullPhone,
|
||||||
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
screenContext,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => CodeConfirmationScreen(
|
builder: (context) => CodeConfirmationScreen(
|
||||||
phoneNumber: '${_selectedCountry.phoneCode} $formattedPhone',
|
phoneNumber:
|
||||||
|
'${_selectedCountry.phoneCode} $formattedPhone',
|
||||||
token: result.token,
|
token: result.token,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -393,7 +417,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
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(
|
TextButton(
|
||||||
onPressed: () => _showOtherLoginMethods(context),
|
onPressed: () => _showOtherLoginMethods(context),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -2,16 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import '../chats/chat_list_screen.dart';
|
import '../chats/chat_list_screen.dart';
|
||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
|
import '../../widgets/custom_notification.dart';
|
||||||
|
|
||||||
class Password2FAScreen extends StatefulWidget {
|
class Password2FAScreen extends StatefulWidget {
|
||||||
final String trackId;
|
final String trackId;
|
||||||
final String? hint;
|
final String? hint;
|
||||||
|
|
||||||
const Password2FAScreen({
|
const Password2FAScreen({super.key, required this.trackId, this.hint});
|
||||||
super.key,
|
|
||||||
required this.trackId,
|
|
||||||
this.hint,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<Password2FAScreen> createState() => _Password2FAScreenState();
|
State<Password2FAScreen> createState() => _Password2FAScreenState();
|
||||||
@@ -43,8 +40,7 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// После успешной 2FA делаем login
|
await accountModule.login();
|
||||||
final loginResult = await accountModule.login();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -60,9 +56,7 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
|||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showCustomNotification(context, 'Неверный пароль: $e');
|
||||||
SnackBar(content: Text('Неверный пароль: $e')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +125,9 @@ class _Password2FAScreenState extends State<Password2FAScreen> {
|
|||||||
),
|
),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isPasswordVisible ? Icons.visibility_off : Icons.visibility,
|
_isPasswordVisible
|
||||||
|
? Icons.visibility_off
|
||||||
|
: Icons.visibility,
|
||||||
color: cs.onSurfaceVariant,
|
color: cs.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
@@ -9,6 +10,10 @@ import 'chat_screen.dart';
|
|||||||
import '../calls/calls_tab.dart';
|
import '../calls/calls_tab.dart';
|
||||||
import '../contacts/contacts_tab.dart';
|
import '../contacts/contacts_tab.dart';
|
||||||
import '../profile/settings_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 {
|
class ChatListScreen extends StatefulWidget {
|
||||||
const ChatListScreen({super.key});
|
const ChatListScreen({super.key});
|
||||||
@@ -25,7 +30,12 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
late AnimationController _fabController;
|
late AnimationController _fabController;
|
||||||
final Set<String> _selectedChats = {};
|
final Set<String> _selectedChats = {};
|
||||||
final ScrollController _scrollController = ScrollController();
|
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;
|
bool get _isSelectionMode => _selectedChats.isNotEmpty;
|
||||||
|
|
||||||
@@ -36,12 +46,21 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
} else {
|
} else {
|
||||||
_selectedChats.add(chatId);
|
_selectedChats.add(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
if (_scrollController.hasClients && _scrollController.offset < 132) {
|
||||||
|
_shouldCollapseSearch = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_shouldCollapseSearch = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedChats.clear();
|
_selectedChats.clear();
|
||||||
|
_shouldCollapseSearch = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +72,45 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
duration: const Duration(milliseconds: 350),
|
duration: const Duration(milliseconds: 350),
|
||||||
);
|
);
|
||||||
_scrollController.addListener(_onScroll);
|
_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() {
|
void _onScroll() {
|
||||||
if (_scrollController.hasClients) {
|
if (_scrollController.hasClients) {
|
||||||
final double offset = _scrollController.offset;
|
final double offset = _scrollController.offset;
|
||||||
|
if (_isSelectionMode && !_shouldCollapseSearch && offset < 132) {
|
||||||
|
setState(() {
|
||||||
|
_shouldCollapseSearch = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (offset < 0) {
|
if (offset < 0) {
|
||||||
final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0);
|
final newRatio = (offset.abs() / 80.0).clamp(0.0, 1.0);
|
||||||
if (newRatio != _pullRatio) {
|
if (newRatio != _pullRatio) {
|
||||||
@@ -75,6 +128,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_stateSub?.cancel();
|
||||||
_fabController.dispose();
|
_fabController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -133,9 +187,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
height: _isSelectionMode
|
height: _shouldCollapseSearch
|
||||||
? 0
|
? 0
|
||||||
: (132 + (96 * _pullRatio)),
|
: (100 + (96 * _pullRatio)),
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
@@ -143,7 +197,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
transform: Matrix4.translationValues(
|
transform: Matrix4.translationValues(
|
||||||
0,
|
0,
|
||||||
_isSelectionMode ? -100 : 0,
|
_shouldCollapseSearch ? -100 : 0,
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -153,7 +207,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
20,
|
20,
|
||||||
12,
|
12,
|
||||||
20,
|
20,
|
||||||
4,
|
2,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment:
|
mainAxisAlignment:
|
||||||
@@ -189,7 +243,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Подключение...',
|
_sessionState == SessionState.online
|
||||||
|
? (_profile?.firstName ?? 'Чат')
|
||||||
|
: 'Подключение...',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onSurface,
|
color: cs.onSurface,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
@@ -266,9 +322,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
20,
|
20,
|
||||||
4,
|
2,
|
||||||
20,
|
20,
|
||||||
12,
|
8,
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 44,
|
height: 44,
|
||||||
@@ -316,9 +372,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.zero,
|
||||||
top: _isSelectionMode ? 64 : 0,
|
|
||||||
),
|
|
||||||
sliver: SliverToBoxAdapter(
|
sliver: SliverToBoxAdapter(
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
@@ -337,7 +391,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 20,
|
horizontal: 20,
|
||||||
vertical: 8,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
@@ -363,53 +417,24 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
_buildChatItem(
|
final chat = _chats[index];
|
||||||
'stas',
|
return _buildChatItem(
|
||||||
'Станислав',
|
chat.id.toString(),
|
||||||
'Хорошо',
|
chat.title ?? 'Чат',
|
||||||
'10:07',
|
chat.lastMsgText ?? '',
|
||||||
'https://i.pravatar.cc/150?u=stas',
|
_formatTime(chat.lastMsgTime),
|
||||||
isOnline: true,
|
(chat.iconUrl != null && chat.iconUrl!.isNotEmpty)
|
||||||
isRead: true,
|
? chat.iconUrl!
|
||||||
),
|
: '',
|
||||||
_buildChatItem(
|
isOnline: chat.isOnline,
|
||||||
'ilya',
|
unreadCount: chat.unreadCount,
|
||||||
'Илья',
|
isMuted: chat.dontDisturbUntil > 0,
|
||||||
'печатает...',
|
);
|
||||||
'10:07',
|
}, childCount: _chats.length),
|
||||||
'https://i.pravatar.cc/150?u=ilya',
|
),
|
||||||
isOnline: true,
|
const SliverPadding(
|
||||||
isTyping: true,
|
padding: EdgeInsets.only(bottom: 120),
|
||||||
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,
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
), // CustomScrollView
|
), // CustomScrollView
|
||||||
@@ -532,7 +557,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_isSelectionMode) ...[
|
if (!_isSelectionMode && _currentNavIndex == 0) ...[
|
||||||
if (_fabController.value > 0)
|
if (_fabController.value > 0)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 20,
|
right: 20,
|
||||||
@@ -726,7 +751,22 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
),
|
),
|
||||||
leading: Stack(
|
leading: Stack(
|
||||||
children: [
|
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)
|
if (isSelected)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: -2,
|
right: -2,
|
||||||
|
|||||||
@@ -58,10 +58,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
titleSpacing: 0,
|
titleSpacing: 0,
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
if (widget.imageUrl.isNotEmpty)
|
||||||
radius: 18,
|
CircleAvatar(
|
||||||
backgroundImage: NetworkImage(widget.imageUrl),
|
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),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -111,11 +121,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Image.network(
|
child: widget.imageUrl.isNotEmpty
|
||||||
'https://images.unsplash.com/photo-1579546929518-9e396f3cc809',
|
? Image.network(
|
||||||
fit: BoxFit.cover,
|
widget.imageUrl,
|
||||||
opacity: const AlwaysStoppedAnimation(0.4),
|
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(
|
const Positioned.fill(
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
|
|||||||
@@ -1,12 +1,42 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.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});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
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(
|
return Scaffold(
|
||||||
backgroundColor: cs.surface,
|
backgroundColor: cs.surface,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@@ -14,7 +44,9 @@ class SettingsTab extends StatelessWidget {
|
|||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(child: _buildHeader(context, cs)),
|
SliverToBoxAdapter(
|
||||||
|
child: _buildHeader(context, cs, fullName, phone),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
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.badge, label: 'Цифровой ID'),
|
||||||
_SettingsItem(
|
_SettingsItem(
|
||||||
icon: Symbols.language,
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 20),
|
padding: const EdgeInsets.fromLTRB(8, 12, 8, 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -95,23 +132,19 @@ class SettingsTab extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: Image.network(
|
child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty
|
||||||
'https://i.pravatar.cc/150?u=ilya',
|
? Image.network(
|
||||||
fit: BoxFit.cover,
|
_profile!.baseUrl!,
|
||||||
errorBuilder: (context, error, stackTrace) => CircleAvatar(
|
fit: BoxFit.cover,
|
||||||
backgroundColor: cs.primaryContainer,
|
errorBuilder: (context, _, __) =>
|
||||||
child: Icon(
|
_buildPlaceholderAvatar(cs, name),
|
||||||
Symbols.person,
|
)
|
||||||
color: cs.onPrimaryContainer,
|
: _buildPlaceholderAvatar(cs, name),
|
||||||
size: 40,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Text(
|
Text(
|
||||||
'Илья Беларуских',
|
name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: cs.onSurface,
|
color: cs.onSurface,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
@@ -119,39 +152,70 @@ class SettingsTab extends StatelessWidget {
|
|||||||
fontFamily: 'Outfit',
|
fontFamily: 'Outfit',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Row(
|
||||||
'@everrnyan',
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: TextStyle(
|
children: [
|
||||||
color: cs.onSurfaceVariant,
|
GestureDetector(
|
||||||
fontSize: 14,
|
onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible),
|
||||||
fontWeight: FontWeight.w400,
|
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(
|
Widget _buildSection(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ColorScheme cs, {
|
ColorScheme cs, {
|
||||||
required List<_SettingsItem> items,
|
required List<_SettingsItem> items,
|
||||||
}) {
|
}) {
|
||||||
return ClipRRect(
|
return Container(
|
||||||
borderRadius: BorderRadius.circular(20),
|
decoration: BoxDecoration(
|
||||||
child: Container(
|
color: cs.surfaceContainerHigh,
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(20),
|
||||||
color: cs.surfaceContainerHigh,
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
child: Column(
|
||||||
),
|
children: List.generate(items.length, (index) {
|
||||||
child: Column(
|
final item = items[index];
|
||||||
children: List.generate(items.length, (index) {
|
final isLast = index == items.length - 1;
|
||||||
final item = items[index];
|
return _buildSettingsRow(context, cs, item, isLast: isLast);
|
||||||
final isLast = index == items.length - 1;
|
}),
|
||||||
return _buildSettingsRow(context, cs, item, isLast: isLast);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -168,6 +232,9 @@ class SettingsTab extends StatelessWidget {
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
|
borderRadius: isLast
|
||||||
|
? const BorderRadius.vertical(bottom: Radius.circular(20))
|
||||||
|
: null,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 17),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -220,3 +287,93 @@ class _SettingsItem {
|
|||||||
|
|
||||||
const _SettingsItem({required this.icon, required this.label});
|
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/api.dart';
|
||||||
import 'backend/modules/account.dart';
|
import 'backend/modules/account.dart';
|
||||||
import 'core/storage/app_database.dart';
|
import 'core/storage/app_database.dart';
|
||||||
|
import 'core/storage/token_storage.dart';
|
||||||
import 'frontend/screens/auth/login_screen.dart';
|
import 'frontend/screens/auth/login_screen.dart';
|
||||||
|
import 'frontend/screens/chats/chat_list_screen.dart';
|
||||||
|
|
||||||
final api = Api();
|
final api = Api();
|
||||||
final accountModule = AccountModule(api);
|
final accountModule = AccountModule(api);
|
||||||
@@ -42,10 +44,12 @@ class MyApp extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DynamicColorBuilder(
|
return DynamicColorBuilder(
|
||||||
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
||||||
final baseScheme = darkDynamic ?? ColorScheme.fromSeed(
|
final baseScheme =
|
||||||
seedColor: _fallbackSeed,
|
darkDynamic ??
|
||||||
brightness: Brightness.dark,
|
ColorScheme.fromSeed(
|
||||||
);
|
seedColor: _fallbackSeed,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
);
|
||||||
|
|
||||||
final darkScheme = _adjustScheme(baseScheme);
|
final darkScheme = _adjustScheme(baseScheme);
|
||||||
|
|
||||||
@@ -55,13 +59,66 @@ class MyApp extends StatelessWidget {
|
|||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: darkScheme,
|
colorScheme: darkScheme,
|
||||||
textTheme: GoogleFonts.interTextTheme(
|
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme),
|
||||||
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+8
-8
@@ -21,10 +21,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -300,18 +300,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
material_symbols_icons:
|
material_symbols_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -601,10 +601,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.10"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user