Merge pull request #9 from KometTeam/feature/server-connection-handshake
Feature/server connection handshake
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import 'chats.dart';
|
||||
|
||||
enum AuthRequestType {
|
||||
startAuth('START_AUTH'),
|
||||
resend('RESEND'),
|
||||
checkCode('CHECK_CODE'),
|
||||
register('REGISTER');
|
||||
|
||||
const AuthRequestType(this.value);
|
||||
final String value;
|
||||
}
|
||||
|
||||
class RequestCodeResult {
|
||||
final String token;
|
||||
|
||||
const RequestCodeResult({required this.token});
|
||||
}
|
||||
|
||||
class VerifyCodeResult {
|
||||
final Map<dynamic, dynamic> payload;
|
||||
|
||||
const VerifyCodeResult({required this.payload});
|
||||
|
||||
String? get loginToken => _nestedToken('LOGIN');
|
||||
|
||||
String? get registerToken => _nestedToken('REGISTER');
|
||||
|
||||
bool get requiresPassword => payload['passwordChallenge'] != null;
|
||||
|
||||
Map<dynamic, dynamic>? get passwordChallenge {
|
||||
final c = payload['passwordChallenge'];
|
||||
return c is Map ? c.cast<dynamic, dynamic>() : null;
|
||||
}
|
||||
|
||||
/// trackId из passwordChallenge — передаётся в [AccountModule.checkPassword].
|
||||
String? get challengeTrackId => passwordChallenge?['trackId'] as String?;
|
||||
|
||||
/// Подсказка к паролю из passwordChallenge.
|
||||
String? get challengeHint => passwordChallenge?['hint'] as String?;
|
||||
|
||||
int? get accountId {
|
||||
final profileData = payload['profile'];
|
||||
if (profileData is! Map) return null;
|
||||
final contact = profileData['contact'];
|
||||
if (contact is! Map) return null;
|
||||
return contact['id'] as int?;
|
||||
}
|
||||
|
||||
String? _nestedToken(String key) {
|
||||
final attrs = payload['tokenAttrs'];
|
||||
if (attrs is! Map) return null;
|
||||
final entry = attrs[key];
|
||||
if (entry is! Map) return null;
|
||||
return entry['token'] as String?;
|
||||
}
|
||||
}
|
||||
|
||||
class TwoFactorResult {
|
||||
final String loginToken;
|
||||
|
||||
const TwoFactorResult({required this.loginToken});
|
||||
}
|
||||
|
||||
/// При отсутствии [LoginSyncParams] в [AccountModule.login] сервер вернёт
|
||||
/// полный снимок данных (cold start), иначе только дельту (warm start).
|
||||
class LoginSyncParams {
|
||||
final int chatsSync;
|
||||
final int contactsSync;
|
||||
final int callsSync;
|
||||
final int draftsSync;
|
||||
final int bannersSync;
|
||||
final int presenceSync;
|
||||
final int lastLogin;
|
||||
final String? configHash;
|
||||
final String? chatCacheFingerprint;
|
||||
|
||||
const LoginSyncParams({
|
||||
required this.chatsSync,
|
||||
required this.contactsSync,
|
||||
required this.callsSync,
|
||||
required this.draftsSync,
|
||||
required this.bannersSync,
|
||||
required this.presenceSync,
|
||||
required this.lastLogin,
|
||||
this.configHash,
|
||||
this.chatCacheFingerprint,
|
||||
});
|
||||
|
||||
static Future<LoginSyncParams?> fromDatabase(int accountId) async {
|
||||
final values = await AppDatabase.getAllSyncValues(accountId);
|
||||
final lastLogin = values[SyncKey.lastLogin];
|
||||
if (lastLogin == null) return null;
|
||||
|
||||
return LoginSyncParams(
|
||||
chatsSync: int.tryParse(values[SyncKey.chatsSync] ?? '') ?? 0,
|
||||
contactsSync: int.tryParse(values[SyncKey.contactsSync] ?? '') ?? 0,
|
||||
callsSync: int.tryParse(values[SyncKey.callsSync] ?? '') ?? 0,
|
||||
draftsSync: int.tryParse(values[SyncKey.draftsSync] ?? '') ?? 0,
|
||||
bannersSync: int.tryParse(values[SyncKey.bannersSync] ?? '') ?? 0,
|
||||
presenceSync: int.tryParse(values[SyncKey.presenceSync] ?? '') ?? -1,
|
||||
lastLogin: int.parse(lastLogin),
|
||||
configHash: values[SyncKey.configHash],
|
||||
chatCacheFingerprint: values[SyncKey.chatCacheFingerprint],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginResult {
|
||||
final ProfileData profile;
|
||||
final String? updatedToken;
|
||||
final int serverTime;
|
||||
|
||||
const LoginResult({
|
||||
required this.profile,
|
||||
required this.updatedToken,
|
||||
required this.serverTime,
|
||||
});
|
||||
}
|
||||
|
||||
class AccountModule {
|
||||
final Api _api;
|
||||
|
||||
AccountModule(this._api);
|
||||
|
||||
Future<RequestCodeResult> requestCode(
|
||||
String phone, {
|
||||
String language = 'ru',
|
||||
}) =>
|
||||
_requestCodeInternal(phone, AuthRequestType.startAuth, language);
|
||||
|
||||
Future<RequestCodeResult> resendCode(
|
||||
String phone, {
|
||||
String language = 'ru',
|
||||
}) =>
|
||||
_requestCodeInternal(phone, AuthRequestType.resend, language);
|
||||
|
||||
Future<VerifyCodeResult> verifyCode(String code, String token) async {
|
||||
_ensureOnline();
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'token': token,
|
||||
'verifyCode': code,
|
||||
'authTokenType': AuthRequestType.checkCode.value,
|
||||
};
|
||||
|
||||
logger.i('Отправка OTP-кода (opcode=${Opcode.auth})');
|
||||
|
||||
final packet = await _api.sendRequest(Opcode.auth, payload);
|
||||
|
||||
_checkPacketError(packet, 'verifyCode');
|
||||
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('verifyCode: неожиданный тип payload: ${data.runtimeType}');
|
||||
}
|
||||
|
||||
final result = VerifyCodeResult(payload: data.cast<dynamic, dynamic>());
|
||||
|
||||
final sessionToken = result.loginToken ?? result.registerToken;
|
||||
final accountId = result.accountId;
|
||||
|
||||
if (sessionToken != null && accountId != null) {
|
||||
await TokenStorage.saveToken(sessionToken, accountId);
|
||||
await TokenStorage.setActiveAccount(accountId);
|
||||
logger.i('Токен аккаунта $accountId сохранён, установлен активным');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<LoginResult> login({
|
||||
int? accountId,
|
||||
String? token,
|
||||
LoginSyncParams? syncParams,
|
||||
}) async {
|
||||
_ensureOnline();
|
||||
|
||||
final resolvedAccountId = accountId ?? await TokenStorage.getActiveAccountId();
|
||||
if (resolvedAccountId == null) {
|
||||
throw StateError('login: нет активного аккаунта');
|
||||
}
|
||||
|
||||
final authToken = token ?? await TokenStorage.readToken(resolvedAccountId);
|
||||
if (authToken == null) {
|
||||
throw StateError('login: нет токена для аккаунта $resolvedAccountId');
|
||||
}
|
||||
|
||||
final resolvedSyncParams =
|
||||
syncParams ?? await LoginSyncParams.fromDatabase(resolvedAccountId);
|
||||
|
||||
final requestPayload = _buildLoginPayload(authToken, resolvedSyncParams);
|
||||
|
||||
logger.i('LOGIN opcode=${Opcode.login} '
|
||||
'account=$resolvedAccountId warm=${resolvedSyncParams != null}');
|
||||
|
||||
final packet = await _api.sendRequest(Opcode.login, requestPayload);
|
||||
|
||||
_checkPacketError(packet, 'login');
|
||||
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('login: неожиданный тип payload: ${data.runtimeType}');
|
||||
}
|
||||
|
||||
return _processLoginResponse(
|
||||
data.cast<dynamic, dynamic>(),
|
||||
resolvedAccountId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProfileData> switchAccount(int accountId) async {
|
||||
final profile = await AppDatabase.loadProfile(accountId);
|
||||
if (profile == null) {
|
||||
throw StateError('switchAccount: аккаунт $accountId не найден в базе');
|
||||
}
|
||||
|
||||
await AppDatabase.setActiveAccount(accountId);
|
||||
await TokenStorage.setActiveAccount(accountId);
|
||||
|
||||
logger.i('Активный аккаунт переключён на $accountId');
|
||||
return profile;
|
||||
}
|
||||
|
||||
Future<void> removeAccount(int accountId) async {
|
||||
await AppDatabase.deleteAccount(accountId);
|
||||
await TokenStorage.deleteAccount(accountId);
|
||||
logger.i('Аккаунт $accountId удалён локально');
|
||||
}
|
||||
|
||||
/// Проверяет 2FA-пароль (opcode 115).
|
||||
///
|
||||
/// [trackId] — из [VerifyCodeResult.challengeTrackId].
|
||||
/// [accountId] — из [VerifyCodeResult.accountId].
|
||||
///
|
||||
/// При неверном пароле бросает [Exception].
|
||||
/// При успехе сохраняет токен и устанавливает аккаунт активным.
|
||||
Future<TwoFactorResult> checkPassword({
|
||||
required String password,
|
||||
required String trackId,
|
||||
required int accountId,
|
||||
}) async {
|
||||
_ensureOnline();
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'trackId': trackId,
|
||||
'password': password,
|
||||
};
|
||||
|
||||
logger.i('Проверка 2FA-пароля для аккаунта $accountId');
|
||||
|
||||
final packet = await _api.sendRequest(
|
||||
Opcode.authLoginCheckPassword,
|
||||
payload,
|
||||
);
|
||||
|
||||
_checkPacketError(packet, 'checkPassword');
|
||||
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('checkPassword: неожиданный тип payload: ${data.runtimeType}');
|
||||
}
|
||||
|
||||
if (data['error'] != null) {
|
||||
throw Exception('checkPassword: неверный пароль');
|
||||
}
|
||||
|
||||
final tokenAttrs = data['tokenAttrs'];
|
||||
if (tokenAttrs is! Map) {
|
||||
throw Exception('checkPassword: отсутствует tokenAttrs в ответе');
|
||||
}
|
||||
|
||||
final loginEntry = tokenAttrs['LOGIN'];
|
||||
if (loginEntry is! Map) {
|
||||
throw Exception('checkPassword: отсутствует tokenAttrs.LOGIN в ответе');
|
||||
}
|
||||
|
||||
final loginToken = loginEntry['token'] as String?;
|
||||
if (loginToken == null || loginToken.isEmpty) {
|
||||
throw Exception('checkPassword: отсутствует токен в ответе');
|
||||
}
|
||||
|
||||
await TokenStorage.saveToken(loginToken, accountId);
|
||||
await TokenStorage.setActiveAccount(accountId);
|
||||
logger.i('2FA пройдена, токен аккаунта $accountId сохранён');
|
||||
|
||||
return TwoFactorResult(loginToken: loginToken);
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic> _buildLoginPayload(
|
||||
String token,
|
||||
LoginSyncParams? sync,
|
||||
) {
|
||||
final payload = <dynamic, dynamic>{
|
||||
'token': token,
|
||||
'interactive': true,
|
||||
'exp': {'chatsCountGroups': '0b32'},
|
||||
};
|
||||
|
||||
if (sync != null) {
|
||||
payload['presenceSync'] = sync.presenceSync;
|
||||
payload['chatsSync'] = sync.chatsSync;
|
||||
payload['contactsSync'] = sync.contactsSync;
|
||||
payload['callsSync'] = sync.callsSync;
|
||||
payload['draftsSync'] = sync.draftsSync;
|
||||
payload['bannersSync'] = sync.bannersSync;
|
||||
payload['lastLogin'] = sync.lastLogin;
|
||||
if (sync.configHash != null) payload['configHash'] = sync.configHash;
|
||||
if (sync.chatCacheFingerprint != null) {
|
||||
payload['chatCacheFingerprint'] = sync.chatCacheFingerprint;
|
||||
}
|
||||
} else {
|
||||
payload['presenceSync'] = 0;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
Future<LoginResult> _processLoginResponse(
|
||||
Map<dynamic, dynamic> data,
|
||||
int accountId,
|
||||
) async {
|
||||
final serverTime =
|
||||
(data['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final updatedToken = data['token'] as String?;
|
||||
if (updatedToken != null) {
|
||||
await TokenStorage.saveToken(updatedToken, accountId);
|
||||
logger.i('Обновлённый токен аккаунта $accountId сохранён');
|
||||
}
|
||||
|
||||
final profileMap = data['profile'];
|
||||
if (profileMap is! Map) {
|
||||
throw Exception('login: отсутствует profile в ответе');
|
||||
}
|
||||
final contact = profileMap['contact'];
|
||||
if (contact is! Map) {
|
||||
throw Exception('login: отсутствует profile.contact в ответе');
|
||||
}
|
||||
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||
await AppDatabase.saveProfile(profile);
|
||||
await AppDatabase.setActiveAccount(profile.id);
|
||||
logger.i('Профиль сохранён: id=${profile.id}, name=${profile.firstName}');
|
||||
|
||||
await _saveSyncState(data, serverTime, profile.id);
|
||||
await ChatsModule.syncFromLoginPayload(data, profile.id, profile.id);
|
||||
|
||||
return LoginResult(
|
||||
profile: profile,
|
||||
updatedToken: updatedToken,
|
||||
serverTime: serverTime,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveSyncState(
|
||||
Map<dynamic, dynamic> data,
|
||||
int serverTime,
|
||||
int accountId,
|
||||
) async {
|
||||
final ts = serverTime.toString();
|
||||
|
||||
Future<void> set(String key, String value) =>
|
||||
AppDatabase.setSyncValue(accountId, key, value);
|
||||
|
||||
await set(SyncKey.serverTime, ts);
|
||||
await set(SyncKey.lastLogin, ts);
|
||||
await set(SyncKey.chatsSync, ts);
|
||||
await set(SyncKey.contactsSync, ts);
|
||||
await set(SyncKey.callsSync, ts);
|
||||
await set(SyncKey.draftsSync, ts);
|
||||
await set(SyncKey.bannersSync, ts);
|
||||
await set(SyncKey.presenceSync, '-1');
|
||||
|
||||
final config = data['config'];
|
||||
if (config is Map) {
|
||||
final hash = config['hash'] as String?;
|
||||
if (hash != null) await set(SyncKey.configHash, hash);
|
||||
}
|
||||
}
|
||||
|
||||
Future<RequestCodeResult> _requestCodeInternal(
|
||||
String phone,
|
||||
AuthRequestType type,
|
||||
String language,
|
||||
) async {
|
||||
_ensureOnline();
|
||||
|
||||
final payload = <dynamic, dynamic>{
|
||||
'phone': phone,
|
||||
'type': type.value,
|
||||
'language': language,
|
||||
};
|
||||
|
||||
logger.i('Запрос OTP-кода: phone=$phone type=${type.value}');
|
||||
|
||||
final packet = await _api.sendRequest(Opcode.authRequest, payload);
|
||||
|
||||
_checkPacketError(packet, 'requestCode');
|
||||
|
||||
final data = packet.payload;
|
||||
if (data is! Map) {
|
||||
throw Exception('requestCode: неожиданный тип payload: ${data.runtimeType}');
|
||||
}
|
||||
|
||||
final token = data['token'];
|
||||
if (token is! String || token.isEmpty) {
|
||||
throw Exception('requestCode: отсутствует token в ответе сервера');
|
||||
}
|
||||
|
||||
logger.i('OTP-код запрошен, получен временный токен');
|
||||
return RequestCodeResult(token: token);
|
||||
}
|
||||
|
||||
void _ensureOnline() {
|
||||
if (_api.state != SessionState.online) {
|
||||
throw StateError(
|
||||
'AccountModule: сессия не онлайн (текущее состояние: ${_api.state.name})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _checkPacketError(Packet packet, String method) {
|
||||
if (packet.isError) {
|
||||
final errMsg = packet.payload is Map
|
||||
? (packet.payload as Map)['message'] ?? packet.payload.toString()
|
||||
: packet.payload?.toString() ?? 'unknown error';
|
||||
throw Exception('$method: ошибка от сервера — $errMsg');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import '../../core/storage/app_database.dart';
|
||||
|
||||
class CachedChat {
|
||||
final int id;
|
||||
final int accountId;
|
||||
final String type;
|
||||
final String? title;
|
||||
final String? iconUrl;
|
||||
final int? lastMsgId;
|
||||
final int? lastMsgTime;
|
||||
final String? lastMsgText;
|
||||
final int? lastMsgSenderId;
|
||||
final int unreadCount;
|
||||
final int lastEventTime;
|
||||
final int cachedAt;
|
||||
|
||||
const CachedChat({
|
||||
required this.id,
|
||||
required this.accountId,
|
||||
required this.type,
|
||||
this.title,
|
||||
this.iconUrl,
|
||||
this.lastMsgId,
|
||||
this.lastMsgTime,
|
||||
this.lastMsgText,
|
||||
this.lastMsgSenderId,
|
||||
required this.unreadCount,
|
||||
required this.lastEventTime,
|
||||
required this.cachedAt,
|
||||
});
|
||||
|
||||
factory CachedChat.fromDbRow(Map<String, dynamic> row) => CachedChat(
|
||||
id: row['id'] as int,
|
||||
accountId: row['account_id'] as int,
|
||||
type: row['type'] as String,
|
||||
title: row['title'] as String?,
|
||||
iconUrl: row['icon_url'] as String?,
|
||||
lastMsgId: row['last_msg_id'] as int?,
|
||||
lastMsgTime: row['last_msg_time'] as int?,
|
||||
lastMsgText: row['last_msg_text'] as String?,
|
||||
lastMsgSenderId: row['last_msg_sender'] as int?,
|
||||
unreadCount: row['unread_count'] as int,
|
||||
lastEventTime: row['last_event_time'] as int,
|
||||
cachedAt: row['cached_at'] as int,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toDbRow() => {
|
||||
'id': id,
|
||||
'account_id': accountId,
|
||||
'type': type,
|
||||
'title': title,
|
||||
'icon_url': iconUrl,
|
||||
'last_msg_id': lastMsgId,
|
||||
'last_msg_time': lastMsgTime,
|
||||
'last_msg_text': lastMsgText,
|
||||
'last_msg_sender': lastMsgSenderId,
|
||||
'unread_count': unreadCount,
|
||||
'last_event_time': lastEventTime,
|
||||
'cached_at': cachedAt,
|
||||
};
|
||||
}
|
||||
|
||||
class ChatsModule {
|
||||
/// Парсит и кэширует чаты из payload opcode 19.
|
||||
///
|
||||
/// Для диалогов разрезолвит имя и аватар из списка [contacts] того же
|
||||
/// ответа. На warm start контакты не приходят — используется существующий
|
||||
/// кэш.
|
||||
static Future<void> syncFromLoginPayload(
|
||||
Map<dynamic, dynamic> data,
|
||||
int accountId,
|
||||
int currentUserId,
|
||||
) async {
|
||||
final chats = data['chats'];
|
||||
if (chats is! List || chats.isEmpty) return;
|
||||
|
||||
final contactsMap = _buildContactsMap(data['contacts']);
|
||||
final cachedAt = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final existingRows = await AppDatabase.loadChats(accountId);
|
||||
final existing = {
|
||||
for (final row in existingRows) row['id'] as int: CachedChat.fromDbRow(row),
|
||||
};
|
||||
|
||||
final rows = chats
|
||||
.whereType<Map>()
|
||||
.map((c) => _parseChat(
|
||||
c.cast<dynamic, dynamic>(),
|
||||
accountId,
|
||||
currentUserId,
|
||||
contactsMap,
|
||||
existing,
|
||||
cachedAt,
|
||||
))
|
||||
.whereType<CachedChat>()
|
||||
.map((c) => c.toDbRow())
|
||||
.toList();
|
||||
|
||||
if (rows.isNotEmpty) {
|
||||
await AppDatabase.saveChats(rows);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<CachedChat>> getChats(int accountId) async {
|
||||
final rows = await AppDatabase.loadChats(accountId);
|
||||
return rows.map(CachedChat.fromDbRow).toList();
|
||||
}
|
||||
|
||||
static Future<void> clearCache(int accountId) =>
|
||||
AppDatabase.clearChatsCache(accountId);
|
||||
|
||||
// internal
|
||||
|
||||
static Map<int, Map<dynamic, dynamic>> _buildContactsMap(dynamic contacts) {
|
||||
if (contacts is! List) return {};
|
||||
final result = <int, Map<dynamic, dynamic>>{};
|
||||
for (final c in contacts.whereType<Map>()) {
|
||||
final id = c['id'];
|
||||
if (id is int) result[id] = c.cast();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static CachedChat? _parseChat(
|
||||
Map<dynamic, dynamic> chat,
|
||||
int accountId,
|
||||
int currentUserId,
|
||||
Map<int, Map<dynamic, dynamic>> contactsMap,
|
||||
Map<int, CachedChat> existing,
|
||||
int cachedAt,
|
||||
) {
|
||||
final id = chat['id'];
|
||||
if (id is! int) return null;
|
||||
|
||||
final type = (chat['type'] as String?) ?? 'DIALOG';
|
||||
|
||||
String? title;
|
||||
String? iconUrl;
|
||||
|
||||
if (type == 'DIALOG') {
|
||||
final otherId = _otherParticipantId(chat['participants'], currentUserId);
|
||||
final contact = otherId != null ? contactsMap[otherId] : null;
|
||||
|
||||
if (contact != null) {
|
||||
title = _nameFromContact(contact);
|
||||
iconUrl = contact['baseUrl'] as String?;
|
||||
} else {
|
||||
// Warm start: контакты не пришли — берём из кэша
|
||||
title = existing[id]?.title;
|
||||
iconUrl = existing[id]?.iconUrl;
|
||||
}
|
||||
} else {
|
||||
title = chat['title'] as String?;
|
||||
iconUrl = chat['baseIconUrl'] as String?;
|
||||
}
|
||||
|
||||
final lastMsg = chat['lastMessage'];
|
||||
int? lastMsgId;
|
||||
int? lastMsgTime;
|
||||
String? lastMsgText;
|
||||
int? lastMsgSenderId;
|
||||
|
||||
if (lastMsg is Map) {
|
||||
lastMsgId = lastMsg['id'] as int?;
|
||||
lastMsgTime = lastMsg['time'] as int?;
|
||||
lastMsgText = lastMsg['text'] as String?;
|
||||
lastMsgSenderId = lastMsg['sender'] as int?;
|
||||
}
|
||||
|
||||
return CachedChat(
|
||||
id: id,
|
||||
accountId: accountId,
|
||||
type: type,
|
||||
title: title,
|
||||
iconUrl: iconUrl,
|
||||
lastMsgId: lastMsgId,
|
||||
lastMsgTime: lastMsgTime,
|
||||
lastMsgText: lastMsgText,
|
||||
lastMsgSenderId: lastMsgSenderId,
|
||||
unreadCount: (chat['newMessages'] as int?) ?? 0,
|
||||
lastEventTime: (chat['lastEventTime'] as int?) ?? 0,
|
||||
cachedAt: cachedAt,
|
||||
);
|
||||
}
|
||||
|
||||
static int? _otherParticipantId(dynamic participants, int currentUserId) {
|
||||
if (participants is! Map) return null;
|
||||
for (final key in participants.keys) {
|
||||
final id = key is int ? key : int.tryParse(key.toString());
|
||||
if (id != null && id != currentUserId) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _nameFromContact(Map<dynamic, dynamic> contact) {
|
||||
final names = contact['names'];
|
||||
if (names is! List || names.isEmpty) return null;
|
||||
final name = names.firstWhere(
|
||||
(n) => n is Map && n['type'] == 'ONEME',
|
||||
orElse: () => names.first,
|
||||
) as Map;
|
||||
return name['name'] as String?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
class ProfileData {
|
||||
final int id;
|
||||
final String firstName;
|
||||
final String? lastName;
|
||||
final int phone;
|
||||
final int? photoId;
|
||||
final String? baseUrl;
|
||||
final String? baseRawUrl;
|
||||
final String country;
|
||||
final int accountStatus;
|
||||
final int updateTime;
|
||||
|
||||
ProfileData({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
this.lastName,
|
||||
required this.phone,
|
||||
this.photoId,
|
||||
this.baseUrl,
|
||||
this.baseRawUrl,
|
||||
required this.country,
|
||||
required this.accountStatus,
|
||||
required this.updateTime,
|
||||
});
|
||||
|
||||
factory ProfileData.fromServerMap(Map<dynamic, dynamic> contact) {
|
||||
final names = contact['names'];
|
||||
String firstName = '';
|
||||
String? lastName;
|
||||
|
||||
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 ProfileData(
|
||||
id: contact['id'] as int,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
phone: contact['phone'] as int,
|
||||
photoId: contact['photoId'] as int?,
|
||||
baseUrl: contact['baseUrl'] as String?,
|
||||
baseRawUrl: contact['baseRawUrl'] as String?,
|
||||
country: (contact['country'] as String?) ?? '',
|
||||
accountStatus: (contact['accountStatus'] as int?) ?? 0,
|
||||
updateTime: (contact['updateTime'] as int?) ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
factory ProfileData.fromDbRow(Map<String, dynamic> row) {
|
||||
return ProfileData(
|
||||
id: row['id'] as int,
|
||||
firstName: row['first_name'] as String,
|
||||
lastName: row['last_name'] as String?,
|
||||
phone: row['phone'] as int,
|
||||
photoId: row['photo_id'] as int?,
|
||||
baseUrl: row['base_url'] as String?,
|
||||
baseRawUrl: row['base_raw_url'] as String?,
|
||||
country: row['country'] as String,
|
||||
accountStatus: row['account_status'] as int,
|
||||
updateTime: row['update_time'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDbRow() => {
|
||||
'id': id,
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
'phone': phone,
|
||||
'photo_id': photoId,
|
||||
'base_url': baseUrl,
|
||||
'base_raw_url': baseRawUrl,
|
||||
'country': country,
|
||||
'account_status': accountStatus,
|
||||
'update_time': updateTime,
|
||||
};
|
||||
}
|
||||
|
||||
abstract class SyncKey {
|
||||
static const chatsSync = 'chats_sync';
|
||||
static const contactsSync = 'contacts_sync';
|
||||
static const callsSync = 'calls_sync';
|
||||
static const draftsSync = 'drafts_sync';
|
||||
static const bannersSync = 'banners_sync';
|
||||
static const presenceSync = 'presence_sync';
|
||||
static const lastLogin = 'last_login';
|
||||
static const configHash = 'config_hash';
|
||||
static const chatCacheFingerprint = 'chat_cache_fingerprint';
|
||||
static const serverTime = 'server_time';
|
||||
}
|
||||
|
||||
class AppDatabase {
|
||||
static Database? _db;
|
||||
|
||||
static Future<void> init() async {
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Database> get _instance async {
|
||||
_db ??= await _open();
|
||||
return _db!;
|
||||
}
|
||||
|
||||
static Future<Database> _open() async {
|
||||
final dbPath = await getDatabasesPath();
|
||||
return openDatabase(
|
||||
join(dbPath, 'komet.db'),
|
||||
version: 3,
|
||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: (db, _) => _createTables(db),
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
if (oldVersion < 2) {
|
||||
await db.execute(
|
||||
'ALTER TABLE profile ADD COLUMN is_active INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
await db.execute('DROP TABLE IF EXISTS sync_state');
|
||||
await db.execute(_syncStateSchema);
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
await db.execute(_chatsCacheSchema);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _createTables(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE profile (
|
||||
id INTEGER PRIMARY KEY,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT,
|
||||
phone INTEGER NOT NULL,
|
||||
photo_id INTEGER,
|
||||
base_url TEXT,
|
||||
base_raw_url TEXT,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
account_status INTEGER NOT NULL DEFAULT 0,
|
||||
update_time INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''');
|
||||
await db.execute(_syncStateSchema);
|
||||
await db.execute(_chatsCacheSchema);
|
||||
}
|
||||
|
||||
static const _syncStateSchema = '''
|
||||
CREATE TABLE sync_state (
|
||||
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, key)
|
||||
)
|
||||
''';
|
||||
|
||||
static const _chatsCacheSchema = '''
|
||||
CREATE TABLE chats_cache (
|
||||
id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT,
|
||||
icon_url TEXT,
|
||||
last_msg_id INTEGER,
|
||||
last_msg_time INTEGER,
|
||||
last_msg_text TEXT,
|
||||
last_msg_sender INTEGER,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_event_time INTEGER NOT NULL DEFAULT 0,
|
||||
cached_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (id, account_id)
|
||||
)
|
||||
''';
|
||||
|
||||
static Future<void> saveProfile(ProfileData profile) async {
|
||||
final db = await _instance;
|
||||
await db.insert(
|
||||
'profile',
|
||||
profile.toDbRow(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<ProfileData?> loadProfile(int accountId) async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query(
|
||||
'profile',
|
||||
where: 'id = ?',
|
||||
whereArgs: [accountId],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return ProfileData.fromDbRow(rows.first);
|
||||
}
|
||||
|
||||
static Future<List<ProfileData>> loadAllProfiles() async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query('profile', orderBy: 'is_active DESC, id ASC');
|
||||
return rows.map(ProfileData.fromDbRow).toList();
|
||||
}
|
||||
|
||||
static Future<ProfileData?> loadActiveProfile() async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query(
|
||||
'profile',
|
||||
where: 'is_active = 1',
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return ProfileData.fromDbRow(rows.first);
|
||||
}
|
||||
|
||||
static Future<void> setActiveAccount(int accountId) async {
|
||||
final db = await _instance;
|
||||
await db.transaction((txn) async {
|
||||
await txn.update('profile', {'is_active': 0});
|
||||
await txn.update(
|
||||
'profile',
|
||||
{'is_active': 1},
|
||||
where: 'id = ?',
|
||||
whereArgs: [accountId],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> deleteAccount(int accountId) async {
|
||||
final db = await _instance;
|
||||
await db.delete('profile', where: 'id = ?', whereArgs: [accountId]);
|
||||
}
|
||||
|
||||
static Future<void> setSyncValue(
|
||||
int accountId,
|
||||
String key,
|
||||
String value,
|
||||
) async {
|
||||
final db = await _instance;
|
||||
await db.insert(
|
||||
'sync_state',
|
||||
{'account_id': accountId, 'key': key, 'value': value},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<String?> getSyncValue(int accountId, String key) async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query(
|
||||
'sync_state',
|
||||
where: 'account_id = ? AND key = ?',
|
||||
whereArgs: [accountId, key],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['value'] as String;
|
||||
}
|
||||
|
||||
static Future<Map<String, String>> getAllSyncValues(int accountId) async {
|
||||
final db = await _instance;
|
||||
final rows = await db.query(
|
||||
'sync_state',
|
||||
where: 'account_id = ?',
|
||||
whereArgs: [accountId],
|
||||
);
|
||||
return {
|
||||
for (final row in rows) row['key'] as String: row['value'] as String,
|
||||
};
|
||||
}
|
||||
|
||||
static Future<void> close() async {
|
||||
await _db?.close();
|
||||
_db = null;
|
||||
}
|
||||
|
||||
// Chats cache
|
||||
|
||||
static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
|
||||
final db = await _instance;
|
||||
final batch = db.batch();
|
||||
for (final row in rows) {
|
||||
batch.insert('chats_cache', row, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> loadChats(int accountId) async {
|
||||
final db = await _instance;
|
||||
return db.query(
|
||||
'chats_cache',
|
||||
where: 'account_id = ?',
|
||||
whereArgs: [accountId],
|
||||
orderBy: 'last_event_time DESC',
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> clearChatsCache(int accountId) async {
|
||||
final db = await _instance;
|
||||
await db.delete('chats_cache', where: 'account_id = ?', whereArgs: [accountId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class TokenStorage {
|
||||
static const _tokenPrefix = 'auth_token_';
|
||||
static const _activeAccountKey = 'active_account_id';
|
||||
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
static Future<void> saveToken(String token, int accountId) =>
|
||||
_storage.write(key: '$_tokenPrefix$accountId', value: token);
|
||||
|
||||
static Future<String?> readToken(int accountId) =>
|
||||
_storage.read(key: '$_tokenPrefix$accountId');
|
||||
|
||||
static Future<void> deleteToken(int accountId) =>
|
||||
_storage.delete(key: '$_tokenPrefix$accountId');
|
||||
|
||||
static Future<void> setActiveAccount(int accountId) =>
|
||||
_storage.write(key: _activeAccountKey, value: accountId.toString());
|
||||
|
||||
static Future<int?> getActiveAccountId() async {
|
||||
final val = await _storage.read(key: _activeAccountKey);
|
||||
return val != null ? int.tryParse(val) : null;
|
||||
}
|
||||
|
||||
static Future<String?> readActiveToken() async {
|
||||
final id = await getActiveAccountId();
|
||||
if (id == null) return null;
|
||||
return readToken(id);
|
||||
}
|
||||
|
||||
/// Удаляет токен аккаунта и, если он был активным, сбрасывает активный аккаунт.
|
||||
static Future<void> deleteAccount(int accountId) async {
|
||||
await deleteToken(accountId);
|
||||
final activeId = await getActiveAccountId();
|
||||
if (activeId == accountId) {
|
||||
await _storage.delete(key: _activeAccountKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'backend/api.dart';
|
||||
import 'core/storage/app_database.dart';
|
||||
|
||||
final api = Api();
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await AppDatabase.init();
|
||||
await api.connect();
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(SECRET REQUIRED IMPORTED_TARGET libsecret-1)
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
+222
-1
@@ -134,6 +134,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -152,6 +200,22 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -260,6 +324,11 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
objective_c:
|
||||
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
@@ -274,12 +343,76 @@ packages:
|
||||
version: "9.3.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.23"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -288,6 +421,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -301,6 +442,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqflite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+3"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
sqflite_common_ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0+2"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: caa693ad15a587a2b4fde093b728131a1827903872171089dedb16f7665d3a91
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -325,6 +522,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -405,6 +610,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.4 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
@@ -37,6 +37,13 @@ dependencies:
|
||||
dart_lz4: ^1.0.0
|
||||
msgpack_dart: ^1.0.1
|
||||
logger: ^2.6.2
|
||||
device_info_plus: ^12.3.0
|
||||
flutter_timezone: ^5.0.1
|
||||
timezone: ^0.11.0
|
||||
flutter_secure_storage: ^10.0.0
|
||||
sqflite: ^2.4.2
|
||||
sqflite_common_ffi: ^2.4.0+2
|
||||
path: ^1.9.1
|
||||
google_fonts: ^6.2.1
|
||||
material_symbols_icons: ^4.2906.0
|
||||
dynamic_color: ^1.8.1
|
||||
|
||||
Reference in New Issue
Block a user