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

This commit is contained in:
Jganenokk
2026-04-03 19:35:45 +07:00
parent 98c72e5d16
commit 91069f53ea
17 changed files with 987 additions and 417 deletions
+5
View File
@@ -0,0 +1,5 @@
Не оставляй комментарии в код
Старайся писать чистый код
Лучше качество чем количество
Когда при исправления какой то ошибки/добавление новой возникает ситуация 50/50 где можно выбрать починить сейчас но костылём, или чинить долго, упорно, может даже вообще не починить и переписать пол приложения - выбирай долго и упорно.
ВМЕСТО СНЕКБАРОВ ИСПОЛЬЗУЙ НАШИ КАСТОМНЫЕ УВЕДОМЛЕНИЕ showCustomNotification(context, 'текст')
+49 -61
View File
@@ -16,12 +16,7 @@ import 'package:timezone/data/latest_all.dart' as tz;
import 'package:timezone/timezone.dart' as tz; import 'package: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;
+30 -20
View File
@@ -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());
} }
} }
} }
+96 -42
View File
@@ -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?;
} }
} }
+56
View File
@@ -0,0 +1,56 @@
import '../../core/storage/app_database.dart';
class ContactsModule {
static Future<void> syncFromLoginPayload(
Map<dynamic, dynamic> data,
int accountId,
) async {
final contacts = data['contacts'];
if (contacts is! List || contacts.isEmpty) return;
final rows = contacts
.whereType<Map>()
.map((c) => _parseContact(c.cast<dynamic, dynamic>(), accountId))
.whereType<Map<String, dynamic>>()
.toList();
if (rows.isNotEmpty) {
await AppDatabase.saveContacts(rows);
}
}
static Map<String, dynamic>? _parseContact(
Map<dynamic, dynamic> contact,
int accountId,
) {
final id = contact['id'];
if (id is! int) return null;
String firstName = '';
String? lastName;
final names = contact['names'];
if (names is List && names.isNotEmpty) {
final name =
names.firstWhere(
(n) => n is Map && n['type'] == 'ONEME',
orElse: () => names.first,
)
as Map;
firstName = (name['firstName'] as String?) ?? '';
lastName = name['lastName'] as String?;
}
return {
'id': id,
'account_id': accountId,
'first_name': firstName,
'last_name': lastName,
'phone': (contact['phone'] as int?) ?? 0,
'photo_id': contact['photoId'] as int?,
'base_url': contact['baseUrl'] as String?,
'base_raw_url': contact['baseRawUrl'] as String?,
'update_time': (contact['updateTime'] as int?) ?? 0,
};
}
}
+83 -29
View File
@@ -35,10 +35,12 @@ class ProfileData {
String? lastName; 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],
);
} }
} }
+21 -16
View File
@@ -1,27 +1,32 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart';
class TokenStorage { 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);
} }
} }
} }
+6 -3
View File
@@ -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);
} }
+3 -2
View File
@@ -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,
), ),
), ),
], ],
+55 -10
View File
@@ -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: () {
+101 -61
View File
@@ -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,
+25 -9
View File
@@ -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(
+196 -39
View File
@@ -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
View File
@@ -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
View File
@@ -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: