feat: add phone auth with 2FA, multi-account storage
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import '../api.dart';
|
import '../api.dart';
|
||||||
import '../../core/protocol/opcode_map.dart';
|
import '../../core/protocol/opcode_map.dart';
|
||||||
import '../../core/protocol/packet.dart';
|
import '../../core/protocol/packet.dart';
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -36,6 +37,20 @@ class VerifyCodeResult {
|
|||||||
return c is Map ? c.cast<dynamic, dynamic>() : null;
|
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) {
|
String? _nestedToken(String key) {
|
||||||
final attrs = payload['tokenAttrs'];
|
final attrs = payload['tokenAttrs'];
|
||||||
if (attrs is! Map) return null;
|
if (attrs is! Map) return null;
|
||||||
@@ -45,11 +60,68 @@ class VerifyCodeResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Поток авторизации по номеру телефона:
|
class TwoFactorResult {
|
||||||
/// 1. [requestCode] → сервер шлёт SMS, возвращает временный токен.
|
final String loginToken;
|
||||||
/// 2. [verifyCode] → клиент отправляет код + токен, получает токен сессии.
|
|
||||||
///
|
const TwoFactorResult({required this.loginToken});
|
||||||
/// При необходимости повторной отправки SMS используйте [resendCode].
|
}
|
||||||
|
|
||||||
|
/// При отсутствии [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 {
|
class AccountModule {
|
||||||
final Api _api;
|
final Api _api;
|
||||||
|
|
||||||
@@ -90,14 +162,225 @@ class AccountModule {
|
|||||||
final result = VerifyCodeResult(payload: data.cast<dynamic, dynamic>());
|
final result = VerifyCodeResult(payload: data.cast<dynamic, dynamic>());
|
||||||
|
|
||||||
final sessionToken = result.loginToken ?? result.registerToken;
|
final sessionToken = result.loginToken ?? result.registerToken;
|
||||||
if (sessionToken != null) {
|
final accountId = result.accountId;
|
||||||
await TokenStorage.save(sessionToken);
|
|
||||||
logger.i('Токен сохранён в хранилище');
|
if (sessionToken != null && accountId != null) {
|
||||||
|
await TokenStorage.saveToken(sessionToken, accountId);
|
||||||
|
await TokenStorage.setActiveAccount(accountId);
|
||||||
|
logger.i('Токен аккаунта $accountId сохранён, установлен активным');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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);
|
||||||
|
|
||||||
|
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(
|
Future<RequestCodeResult> _requestCodeInternal(
|
||||||
String phone,
|
String phone,
|
||||||
AuthRequestType type,
|
AuthRequestType type,
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
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: 2,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,42 @@
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
class TokenStorage {
|
class TokenStorage {
|
||||||
static const _key = 'auth_token';
|
static const _tokenPrefix = 'auth_token_';
|
||||||
|
static const _activeAccountKey = 'active_account_id';
|
||||||
|
|
||||||
static const _storage = FlutterSecureStorage(
|
static const _storage = FlutterSecureStorage(
|
||||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
);
|
);
|
||||||
|
|
||||||
static Future<void> save(String token) =>
|
static Future<void> saveToken(String token, int accountId) =>
|
||||||
_storage.write(key: _key, value: token);
|
_storage.write(key: '$_tokenPrefix$accountId', value: token);
|
||||||
|
|
||||||
static Future<String?> read() => _storage.read(key: _key);
|
static Future<String?> readToken(int accountId) =>
|
||||||
|
_storage.read(key: '$_tokenPrefix$accountId');
|
||||||
|
|
||||||
static Future<void> delete() => _storage.delete(key: _key);
|
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,10 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'backend/api.dart';
|
import 'backend/api.dart';
|
||||||
|
import 'core/storage/app_database.dart';
|
||||||
|
|
||||||
final api = Api();
|
final api = Api();
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await AppDatabase.init();
|
||||||
await api.connect();
|
await api.connect();
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-1
@@ -329,7 +329,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "9.3.0"
|
version: "9.3.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path
|
name: path
|
||||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
@@ -421,6 +421,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
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:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -445,6 +501,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.0"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ dependencies:
|
|||||||
flutter_timezone: ^5.0.1
|
flutter_timezone: ^5.0.1
|
||||||
timezone: ^0.11.0
|
timezone: ^0.11.0
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
sqflite: ^2.4.2
|
||||||
|
sqflite_common_ffi: ^2.4.0+2
|
||||||
|
path: ^1.9.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user