805 lines
23 KiB
Dart
805 lines
23 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
|
|
import '../../core/cache/info_cache.dart';
|
|
import '../../core/config/debug_test.dart';
|
|
import '../../core/protocol/opcode_map.dart';
|
|
import '../../core/protocol/packet.dart';
|
|
import '../../core/storage/app_database.dart';
|
|
import '../../core/utils/logger.dart';
|
|
import '../../models/contact_info.dart';
|
|
import '../api.dart';
|
|
import 'messages.dart';
|
|
|
|
class CachedContact {
|
|
final int id;
|
|
final int accountId;
|
|
final String firstName;
|
|
final String? lastName;
|
|
final int phone;
|
|
final int? photoId;
|
|
final String? baseUrl;
|
|
final String? baseRawUrl;
|
|
final int updateTime;
|
|
final Set<String> options;
|
|
final int accountStatus;
|
|
|
|
const CachedContact({
|
|
required this.id,
|
|
required this.accountId,
|
|
required this.firstName,
|
|
this.lastName,
|
|
required this.phone,
|
|
this.photoId,
|
|
this.baseUrl,
|
|
this.baseRawUrl,
|
|
required this.updateTime,
|
|
this.options = const {},
|
|
this.accountStatus = 0,
|
|
});
|
|
|
|
bool get isOfficial => options.contains('OFFICIAL');
|
|
bool get isBot => options.contains('BOT');
|
|
bool get isServiceAccount => options.contains('SERVICE_ACCOUNT');
|
|
bool get isVerified => isOfficial;
|
|
bool get isDeleted => accountStatus != 0;
|
|
|
|
factory CachedContact.fromDbRow(Map<String, dynamic> row) => CachedContact(
|
|
id: row['id'] as int,
|
|
accountId: row['account_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?,
|
|
updateTime: row['update_time'] as int,
|
|
options: _decodeOptions(row['options']),
|
|
accountStatus: (row['account_status'] as int?) ?? 0,
|
|
);
|
|
|
|
static Set<String> _decodeOptions(dynamic raw) {
|
|
if (raw is! String || raw.isEmpty) return const {};
|
|
return raw.split(',').where((s) => s.isNotEmpty).toSet();
|
|
}
|
|
}
|
|
|
|
class PhoneLookupResult {
|
|
final int id;
|
|
final String? name;
|
|
final String? avatarUrl;
|
|
final int phone;
|
|
|
|
const PhoneLookupResult({
|
|
required this.id,
|
|
this.name,
|
|
this.avatarUrl,
|
|
this.phone = 0,
|
|
});
|
|
}
|
|
|
|
class ContactPhotos {
|
|
final List<String> urls;
|
|
final List<int> ids;
|
|
final int total;
|
|
|
|
const ContactPhotos({
|
|
required this.urls,
|
|
this.ids = const [],
|
|
required this.total,
|
|
});
|
|
|
|
static const empty = ContactPhotos(urls: [], total: 0);
|
|
|
|
int? idAt(int index) => index >= 0 && index < ids.length ? ids[index] : null;
|
|
}
|
|
|
|
enum AddContactStatus { added, notFound, error }
|
|
|
|
class AddContactResult {
|
|
final AddContactStatus status;
|
|
final CachedContact? contact;
|
|
|
|
const AddContactResult(this.status, {this.contact});
|
|
}
|
|
|
|
class ContactsModule {
|
|
static final ValueNotifier<int> revision = ValueNotifier<int>(0);
|
|
|
|
static Future<PhoneLookupResult?> findByPhone(
|
|
Api api,
|
|
String phone, {
|
|
bool silent = false,
|
|
bool strict = false,
|
|
}) async {
|
|
final normalized = _normalizePhone(phone);
|
|
if (normalized == null) return null;
|
|
final Packet packet;
|
|
try {
|
|
packet = await api.sendRequest(Opcode.contactInfoByPhone, {
|
|
'phone': normalized,
|
|
}, silent: silent);
|
|
} on PacketError catch (e) {
|
|
if (strict && e.errorKey != 'user.not.found') rethrow;
|
|
return null;
|
|
}
|
|
if (packet.isError) {
|
|
if (strict) throw StateError('Contact lookup failed');
|
|
return null;
|
|
}
|
|
final contact = (packet.payload as Map?)?['contact'];
|
|
if (contact is! Map) return null;
|
|
final id = contact['id'];
|
|
if (id is! int) return null;
|
|
if (((contact['accountStatus'] as int?) ?? 0) != 0) return null;
|
|
|
|
primeContactCache(contact);
|
|
|
|
final payloadPhone = contact['phone'];
|
|
final resolvedPhone = payloadPhone is int && payloadPhone > 0
|
|
? payloadPhone
|
|
: int.tryParse(normalized.substring(1)) ?? 0;
|
|
ContactCache.putPhone(id, resolvedPhone);
|
|
|
|
String? name;
|
|
final names = contact['names'];
|
|
if (names is List) {
|
|
final n = names.firstWhere((e) => e is Map, orElse: () => null);
|
|
if (n is Map) {
|
|
final first =
|
|
(n['firstName'] as String?) ?? (n['name'] as String?) ?? '';
|
|
final last = (n['lastName'] as String?) ?? '';
|
|
final full = '$first $last'.trim();
|
|
if (full.isNotEmpty) name = full;
|
|
}
|
|
}
|
|
|
|
return PhoneLookupResult(
|
|
id: id,
|
|
name: name,
|
|
avatarUrl: contact['baseUrl'] as String?,
|
|
phone: resolvedPhone,
|
|
);
|
|
}
|
|
|
|
static String? _normalizePhone(String raw) {
|
|
var digits = raw.replaceAll(RegExp(r'[^\d]'), '');
|
|
if (digits.length < 10 || digits.length > 15) return null;
|
|
if (digits.length == 11 && digits.startsWith('8')) {
|
|
digits = '7${digits.substring(1)}';
|
|
}
|
|
return '+$digits';
|
|
}
|
|
|
|
static Future<CachedContact?> addContact(
|
|
Api api,
|
|
int id,
|
|
String firstName, {
|
|
int phone = 0,
|
|
}) async {
|
|
final resp = await api.sendRequest(Opcode.contactUpdate, {
|
|
'action': 'ADD',
|
|
'contactId': id,
|
|
if (firstName.isNotEmpty) 'firstName': firstName,
|
|
}, silent: true);
|
|
if (!resp.isOk) return null;
|
|
|
|
final profile = await AppDatabase.loadActiveProfile();
|
|
if (profile == null) return null;
|
|
|
|
final data = resp.payload;
|
|
final contact = (data is Map && data['contact'] is Map)
|
|
? (data['contact'] as Map).cast<dynamic, dynamic>()
|
|
: null;
|
|
|
|
final row = contact != null
|
|
? _parseContact(contact, profile.id)
|
|
: {
|
|
'id': id,
|
|
'account_id': profile.id,
|
|
'first_name': firstName,
|
|
'last_name': null,
|
|
'phone': 0,
|
|
'photo_id': null,
|
|
'base_url': null,
|
|
'base_raw_url': null,
|
|
'update_time': 0,
|
|
'options': null,
|
|
'account_status': 0,
|
|
};
|
|
|
|
if (row == null) return null;
|
|
if (phone > 0 && ((row['phone'] as int?) ?? 0) == 0) {
|
|
row['phone'] = phone;
|
|
}
|
|
await AppDatabase.saveContacts([row]);
|
|
if (contact != null) primeContactCache(contact);
|
|
revision.value++;
|
|
return CachedContact.fromDbRow(row);
|
|
}
|
|
|
|
static Future<AddContactResult> addContactByPhone(
|
|
Api api, {
|
|
required String phone,
|
|
required String firstName,
|
|
String lastName = '',
|
|
int? expectedAccountId,
|
|
}) async {
|
|
final epoch = api.sessionEpoch;
|
|
if (expectedAccountId != null &&
|
|
((await AppDatabase.loadActiveProfile())?.id != expectedAccountId ||
|
|
api.sessionEpoch != epoch)) {
|
|
return const AddContactResult(AddContactStatus.error);
|
|
}
|
|
final normalized = _normalizePhone(phone);
|
|
if (normalized == null) {
|
|
return const AddContactResult(AddContactStatus.error);
|
|
}
|
|
|
|
final Packet resp;
|
|
try {
|
|
resp = await api.sendRequest(Opcode.contactAddByPhone, {
|
|
'phone': normalized,
|
|
'firstName': firstName,
|
|
'lastName': lastName,
|
|
}, silent: true);
|
|
} on PacketError catch (e) {
|
|
final notFound = e.errorKey == 'user.not.found';
|
|
return AddContactResult(
|
|
notFound ? AddContactStatus.notFound : AddContactStatus.error,
|
|
);
|
|
} catch (_) {
|
|
return const AddContactResult(AddContactStatus.error);
|
|
}
|
|
|
|
final data = resp.payload;
|
|
final contact = (data is Map && data['contact'] is Map)
|
|
? (data['contact'] as Map).cast<dynamic, dynamic>()
|
|
: null;
|
|
if (contact == null) {
|
|
return const AddContactResult(AddContactStatus.error);
|
|
}
|
|
|
|
final profile = await AppDatabase.loadActiveProfile();
|
|
if (profile == null ||
|
|
(expectedAccountId != null &&
|
|
(profile.id != expectedAccountId || api.sessionEpoch != epoch))) {
|
|
return const AddContactResult(AddContactStatus.error);
|
|
}
|
|
|
|
final row = _parseContact(contact, profile.id);
|
|
if (row == null) {
|
|
return const AddContactResult(AddContactStatus.error);
|
|
}
|
|
|
|
await AppDatabase.saveContacts([row]);
|
|
primeContactCache(contact);
|
|
revision.value++;
|
|
return AddContactResult(
|
|
AddContactStatus.added,
|
|
contact: CachedContact.fromDbRow(row),
|
|
);
|
|
}
|
|
|
|
static Future<CachedContact?> updateContact(
|
|
Api api, {
|
|
required int contactId,
|
|
required String firstName,
|
|
required String lastName,
|
|
}) async {
|
|
final epoch = api.sessionEpoch;
|
|
final profile = await AppDatabase.loadActiveProfile();
|
|
if (profile == null || api.sessionEpoch != epoch) return null;
|
|
void beforeSend() {
|
|
if (api.sessionEpoch != epoch) {
|
|
throw StateError('Сессия изменилась до сохранения контакта');
|
|
}
|
|
}
|
|
|
|
final payload = <String, dynamic>{
|
|
'contactId': contactId,
|
|
'action': 'UPDATE',
|
|
'firstName': firstName,
|
|
'lastName': lastName,
|
|
};
|
|
Packet resp;
|
|
try {
|
|
resp = await api.sendRequest(
|
|
Opcode.contactUpdate,
|
|
payload,
|
|
silent: true,
|
|
beforeSend: beforeSend,
|
|
);
|
|
} on PacketError catch (error) {
|
|
// A contact removed on another client can still be in the local cache.
|
|
// Saving its name explicitly restores that relation on IKAR.
|
|
if (error.errorKey != 'contact.not.found' ||
|
|
!await _isCurrentSession(api, epoch, profile.id)) {
|
|
return null;
|
|
}
|
|
try {
|
|
resp = await api.sendRequest(
|
|
Opcode.contactUpdate,
|
|
{...payload, 'action': 'ADD'},
|
|
silent: true,
|
|
beforeSend: beforeSend,
|
|
);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
|
|
if (!resp.isOk || !await _isCurrentSession(api, epoch, profile.id)) {
|
|
return null;
|
|
}
|
|
|
|
final data = resp.payload;
|
|
final contact = (data is Map && data['contact'] is Map)
|
|
? (data['contact'] as Map).cast<dynamic, dynamic>()
|
|
: null;
|
|
if (contact == null) return null;
|
|
|
|
final row = _parseContact(contact, profile.id);
|
|
if (row == null) return null;
|
|
|
|
await AppDatabase.saveContacts([row]);
|
|
primeContactCache(contact);
|
|
ContactInfoFetch.putContact(contactId, contact);
|
|
revision.value++;
|
|
return CachedContact.fromDbRow(row);
|
|
}
|
|
|
|
static Future<bool> removeContact(Api api, int contactId) async {
|
|
try {
|
|
final packet = await api.sendRequest(Opcode.contactUpdate, {
|
|
'contactId': contactId,
|
|
'action': 'REMOVE',
|
|
}, silent: true);
|
|
if (!packet.isOk) return false;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
|
|
final profile = await AppDatabase.loadActiveProfile();
|
|
if (profile != null) {
|
|
await AppDatabase.deleteContact(profile.id, contactId);
|
|
}
|
|
|
|
ContactCache.remove(contactId);
|
|
|
|
ContactInfo? info = ContactInfoFetch.peek(contactId);
|
|
if (info == null) {
|
|
ContactInfoFetch.invalidate(contactId);
|
|
info = await ContactInfoFetch.get(contactId, forceRefresh: true);
|
|
}
|
|
|
|
final rawNames = info?.raw['names'];
|
|
if (info != null && rawNames is List) {
|
|
final stripped = rawNames
|
|
.where((n) => !(n is Map && n['type'] == 'CUSTOM'))
|
|
.toList();
|
|
final newRaw = Map<String, dynamic>.from(info.raw)..['names'] = stripped;
|
|
ContactInfoFetch.putContact(contactId, newRaw);
|
|
primeContactCache(newRaw);
|
|
} else {
|
|
ContactInfoFetch.invalidate(contactId);
|
|
}
|
|
|
|
revision.value++;
|
|
return true;
|
|
}
|
|
|
|
static final Set<int> _blockedIds = <int>{};
|
|
static bool _blockedLoaded = false;
|
|
static Future<void>? _blockedLoading;
|
|
static int _blockedGeneration = 0;
|
|
|
|
static Set<int> get blockedIds => Set.unmodifiable(_blockedIds);
|
|
|
|
static void clearBlockedCache() {
|
|
_blockedIds.clear();
|
|
_blockedLoaded = false;
|
|
_blockedLoading = null;
|
|
_blockedGeneration++;
|
|
}
|
|
|
|
static Future<void> ensureBlockedLoaded(Api api) {
|
|
if (_blockedLoaded) return Future.value();
|
|
return _blockedLoading ??= _loadBlockedIds(
|
|
api,
|
|
).whenComplete(() => _blockedLoading = null);
|
|
}
|
|
|
|
static const int _blockedPageSize = 100;
|
|
static const int _blockedMaxPages = 20;
|
|
|
|
static Future<bool> isBlocked(Api api, int contactId) async {
|
|
await ensureBlockedLoaded(api);
|
|
return _blockedIds.contains(contactId);
|
|
}
|
|
|
|
static Future<void> _loadBlockedIds(Api api) async {
|
|
final generation = _blockedGeneration;
|
|
final ids = <int>{};
|
|
try {
|
|
for (var page = 0; page < _blockedMaxPages; page++) {
|
|
final map = await api.sendRequestMap(Opcode.contactList, {
|
|
'status': 'BLOCKED',
|
|
'count': _blockedPageSize,
|
|
'from': page * _blockedPageSize,
|
|
});
|
|
final contacts = map?['contacts'];
|
|
if (contacts is! List) return;
|
|
ids.addAll(
|
|
contacts.whereType<Map>().map((c) => c['id']).whereType<int>(),
|
|
);
|
|
if (contacts.length < _blockedPageSize) break;
|
|
}
|
|
} catch (e) {
|
|
logger.w('Не удалось получить список заблокированных: $e');
|
|
return;
|
|
}
|
|
if (generation != _blockedGeneration) return;
|
|
final changed = !setEquals(_blockedIds, ids);
|
|
_blockedIds
|
|
..clear()
|
|
..addAll(ids);
|
|
_blockedLoaded = true;
|
|
if (changed) revision.value++;
|
|
}
|
|
|
|
static Future<bool> setBlocked(Api api, int contactId, bool blocked) async {
|
|
try {
|
|
final packet = await api.sendRequest(Opcode.contactUpdate, {
|
|
'contactId': contactId,
|
|
'action': blocked ? 'BLOCK' : 'UNBLOCK',
|
|
});
|
|
if (packet.isError) return false;
|
|
} catch (e) {
|
|
logger.w('setBlocked $contactId: $e');
|
|
return false;
|
|
}
|
|
|
|
if (blocked) {
|
|
_blockedIds.add(contactId);
|
|
} else {
|
|
_blockedIds.remove(contactId);
|
|
}
|
|
ContactInfoFetch.invalidate(contactId);
|
|
revision.value++;
|
|
return true;
|
|
}
|
|
|
|
static Future<void> syncFromLoginPayload(
|
|
Map<dynamic, dynamic> data,
|
|
int accountId,
|
|
) => _applyContacts(data, accountId, complete: false);
|
|
|
|
static Future<void> applyFullContactList(
|
|
Map<dynamic, dynamic> data,
|
|
int accountId,
|
|
) => _applyContacts(data, accountId, complete: true);
|
|
|
|
static Future<void> _applyContacts(
|
|
Map<dynamic, dynamic> data,
|
|
int accountId, {
|
|
required bool complete,
|
|
Future<bool> Function()? isCurrent,
|
|
}) async {
|
|
final contacts = data['contacts'];
|
|
if (contacts is! List) {
|
|
logger.i('Контакты: сервер не прислал список (акк $accountId)');
|
|
return;
|
|
}
|
|
if (contacts.isEmpty && !complete) {
|
|
logger.i('Контакты: сервер прислал пустой список (акк $accountId)');
|
|
return;
|
|
}
|
|
|
|
final rows = <Map<String, dynamic>>[];
|
|
final parsedContacts = <Map<dynamic, dynamic>>[];
|
|
final removedIds = <int>{};
|
|
final presentIds = <int>{};
|
|
for (final raw in contacts.whereType<Map>()) {
|
|
final contact = raw.cast<dynamic, dynamic>();
|
|
parsedContacts.add(contact);
|
|
final id = contact['id'];
|
|
if (contact['status'] == 'REMOVED') {
|
|
if (id is int) removedIds.add(id);
|
|
continue;
|
|
}
|
|
if (id is int) presentIds.add(id);
|
|
final row = _parseContact(contact, accountId);
|
|
if (row != null) rows.add(row);
|
|
}
|
|
if (complete) {
|
|
final localIds = await AppDatabase.loadContactIds(accountId);
|
|
removedIds.addAll(localIds.where((id) => !presentIds.contains(id)));
|
|
}
|
|
if (isCurrent != null && !await isCurrent()) return;
|
|
|
|
if (rows.isNotEmpty) await AppDatabase.saveContacts(rows);
|
|
if (removedIds.isNotEmpty) {
|
|
await AppDatabase.deleteContacts(accountId, removedIds.toList());
|
|
}
|
|
if (isCurrent != null && !await isCurrent()) return;
|
|
for (final contact in parsedContacts) {
|
|
primeContactCache(contact);
|
|
}
|
|
if (removedIds.isNotEmpty) {
|
|
for (final id in removedIds) {
|
|
ContactCache.remove(id);
|
|
ContactInfoFetch.invalidate(id);
|
|
}
|
|
}
|
|
if (rows.isNotEmpty || removedIds.isNotEmpty) revision.value++;
|
|
logger.i(
|
|
'Контакты: получено ${contacts.length}, сохранено ${rows.length} (акк $accountId)',
|
|
);
|
|
}
|
|
|
|
static Future<void> syncFromServer(Api api, int accountId) async {
|
|
final epoch = api.sessionEpoch;
|
|
final revisionAtRequest = revision.value;
|
|
Future<bool> isCurrent() async =>
|
|
await _isCurrentSession(api, epoch, accountId) &&
|
|
revision.value == revisionAtRequest;
|
|
if (!await isCurrent()) return;
|
|
final packet = await api.sendRequest(
|
|
Opcode.contactsGet,
|
|
{'contactsSync': 0},
|
|
beforeSend: () {
|
|
if (api.sessionEpoch != epoch) {
|
|
throw StateError('Сессия изменилась до синхронизации контактов');
|
|
}
|
|
},
|
|
);
|
|
final map = packet.payload;
|
|
if (!packet.isOk || map is! Map || !await isCurrent()) return;
|
|
await _applyContacts(
|
|
map.cast<dynamic, dynamic>(),
|
|
accountId,
|
|
complete: true,
|
|
isCurrent: isCurrent,
|
|
);
|
|
}
|
|
|
|
static Future<bool> _isCurrentSession(
|
|
Api api,
|
|
int epoch,
|
|
int accountId,
|
|
) async {
|
|
final profile = await AppDatabase.loadActiveProfile();
|
|
return profile?.id == accountId && api.sessionEpoch == epoch;
|
|
}
|
|
|
|
static Future<ProfileData?> fetchSelfProfile(Api api, int accountId) async {
|
|
final map = await api.sendRequestMap(Opcode.contactInfo, {
|
|
'contactIds': [accountId],
|
|
});
|
|
final contacts = map?['contacts'];
|
|
if (contacts is! List) return null;
|
|
for (final raw in contacts.whereType<Map>()) {
|
|
if (raw['id'] != accountId) continue;
|
|
final contact = raw.cast<dynamic, dynamic>();
|
|
primeContactCache(contact);
|
|
return ProfileData.fromServerMap(contact);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
static void primeContactCache(Map<dynamic, dynamic> contact) {
|
|
final id = contact['id'];
|
|
if (id is! int) return;
|
|
|
|
final phone = contact['phone'];
|
|
if (phone is int) ContactCache.putPhone(id, phone);
|
|
|
|
final names = contact['names'];
|
|
if (names is List && names.isNotEmpty) {
|
|
final nameRaw = _preferredNameEntry(names);
|
|
if (nameRaw != null) {
|
|
final firstName = (nameRaw['firstName'] as String?) ?? '';
|
|
final lastName = nameRaw['lastName'] as String?;
|
|
final fullName = (lastName != null && lastName.isNotEmpty)
|
|
? '$firstName $lastName'
|
|
: firstName;
|
|
if (fullName.isNotEmpty) ContactCache.put(id, fullName);
|
|
}
|
|
}
|
|
|
|
final baseUrl = contact['baseUrl'] as String?;
|
|
if (baseUrl != null && baseUrl.isNotEmpty) {
|
|
ContactCache.putAvatar(id, baseUrl);
|
|
}
|
|
}
|
|
|
|
static final Map<int, ContactPhotos> _photosHead = {};
|
|
|
|
static ContactPhotos? cachedPhotos(int contactId) => _photosHead[contactId];
|
|
|
|
static Future<ContactPhotos> fetchPhotos(
|
|
Api api,
|
|
int contactId, {
|
|
int from = 0,
|
|
int count = 25,
|
|
}) async {
|
|
if (contactId <= 0) return ContactPhotos.empty;
|
|
final map = await api.sendRequestMap(Opcode.contactPhotos, {
|
|
'contactId': contactId,
|
|
'from': from,
|
|
'count': count,
|
|
});
|
|
if (map == null) return ContactPhotos.empty;
|
|
final rawUrls = map['urls'];
|
|
final urls = rawUrls is List
|
|
? rawUrls.whereType<String>().toList()
|
|
: <String>[];
|
|
final rawIds = map['ids'];
|
|
final ids = rawIds is List ? rawIds.whereType<int>().toList() : <int>[];
|
|
final total = map['total'] is int ? map['total'] as int : urls.length;
|
|
final photos = ContactPhotos(
|
|
urls: urls,
|
|
ids: ids.length == urls.length ? ids : const [],
|
|
total: total,
|
|
);
|
|
if (from == 0) _photosHead[contactId] = photos;
|
|
return photos;
|
|
}
|
|
|
|
static void invalidatePhotos(int contactId) {
|
|
_photosHead.remove(contactId);
|
|
}
|
|
|
|
static Future<List<CachedContact>> getContacts(
|
|
int accountId, {
|
|
bool includeDeleted = false,
|
|
}) async {
|
|
final rows = await AppDatabase.loadContacts(
|
|
accountId,
|
|
includeDeleted: includeDeleted,
|
|
);
|
|
return rows.map(CachedContact.fromDbRow).toList();
|
|
}
|
|
|
|
static Future<CachedContact?> getContact(int accountId, int id) async {
|
|
final row = await AppDatabase.loadContact(accountId, id);
|
|
return row == null ? null : CachedContact.fromDbRow(row);
|
|
}
|
|
|
|
static const List<String> _debugFirstNames = [
|
|
'Алиса',
|
|
'Борис',
|
|
'Вера',
|
|
'Глеб',
|
|
'Дарья',
|
|
'Егор',
|
|
'Жанна',
|
|
'Захар',
|
|
'Ирина',
|
|
'Кирилл',
|
|
'Лия',
|
|
'Максим',
|
|
'Нина',
|
|
'Олег',
|
|
'Полина',
|
|
'Роман',
|
|
'София',
|
|
'Тимур',
|
|
'Ульяна',
|
|
'Фёдор',
|
|
'Ханна',
|
|
'Цветана',
|
|
'Чеслав',
|
|
'Шура',
|
|
];
|
|
|
|
static const List<String> _debugLastNames = [
|
|
'Иванов',
|
|
'Петров',
|
|
'Сидоров',
|
|
'Кузнецов',
|
|
'Смирнов',
|
|
'Попов',
|
|
'Волков',
|
|
'Соколов',
|
|
'Морозов',
|
|
'Новиков',
|
|
'Фёдоров',
|
|
'Козлов',
|
|
];
|
|
|
|
static List<CachedContact> debugContacts() {
|
|
final count = DebugTest.contactCount;
|
|
final out = <CachedContact>[];
|
|
for (var i = 0; i < count; i++) {
|
|
final first = _debugFirstNames[i % _debugFirstNames.length];
|
|
final last =
|
|
_debugLastNames[(i ~/ _debugFirstNames.length) %
|
|
_debugLastNames.length];
|
|
out.add(
|
|
CachedContact(
|
|
id: 900000000 + i,
|
|
accountId: DebugTest.debugAccountId,
|
|
firstName: '$first ${i + 1}',
|
|
lastName: last,
|
|
phone: 79000000000 + i,
|
|
baseUrl: 'https://i.pravatar.cc/150?u=qlyra_debug_$i',
|
|
updateTime: 1,
|
|
options: i % 6 == 0 ? const {'OFFICIAL'} : const {},
|
|
),
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// Прогревает in-memory ContactCache из локальных контактов.
|
|
/// Нужно вызывать на cold start: иначе кэш пуст до следующего логина.
|
|
static Future<void> primeCacheFromDb(int accountId) async {
|
|
final contacts = await getContacts(accountId, includeDeleted: true);
|
|
for (final c in contacts) {
|
|
ContactCache.putPhone(c.id, c.phone);
|
|
final fullName = (c.lastName != null && c.lastName!.isNotEmpty)
|
|
? '${c.firstName} ${c.lastName}'
|
|
: c.firstName;
|
|
if (fullName.isNotEmpty) ContactCache.put(c.id, fullName);
|
|
if (c.baseUrl != null && c.baseUrl!.isNotEmpty) {
|
|
ContactCache.putAvatar(c.id, c.baseUrl);
|
|
}
|
|
}
|
|
}
|
|
|
|
static Map? _preferredNameEntry(List names) {
|
|
Map? oneme;
|
|
Map? any;
|
|
for (final n in names) {
|
|
if (n is! Map) continue;
|
|
any ??= n;
|
|
final type = n['type'];
|
|
if (type == 'CUSTOM') return n;
|
|
if (type == 'ONEME') oneme ??= n;
|
|
}
|
|
return oneme ?? any;
|
|
}
|
|
|
|
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 nameRaw = _preferredNameEntry(names);
|
|
if (nameRaw == null) return null;
|
|
firstName = (nameRaw['firstName'] as String?) ?? '';
|
|
lastName = nameRaw['lastName'] as String?;
|
|
}
|
|
|
|
final optionsRaw = contact['options'];
|
|
String? optionsStr;
|
|
if (optionsRaw is List) {
|
|
optionsStr = optionsRaw.whereType<String>().join(',');
|
|
}
|
|
|
|
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,
|
|
'options': optionsStr,
|
|
'account_status': (contact['accountStatus'] as int?) ?? 0,
|
|
};
|
|
}
|
|
}
|