feat(contacts): имена из телефонной книги везде + тумблер в дев-меню

This commit is contained in:
klockky
2026-07-16 11:49:52 +03:00
parent cf8e50c6f7
commit 131c1dc775
13 changed files with 333 additions and 15 deletions
+1
View File
@@ -23,6 +23,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
<uses-permission android:name="android.permission.NFC"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-feature android:name="android.hardware.nfc.hce" android:required="false"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
+2
View File
@@ -70,6 +70,8 @@
<string>Доступ к галерее нужен, чтобы отправлять фото и видео в чатах.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Доступ к галерее нужен, чтобы сохранять полученные фото и видео.</string>
<key>NSContactsUsageDescription</key>
<string>Доступ к контактам нужен, чтобы показывать имена собеседников так, как они записаны в вашей телефонной книге.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
+24 -5
View File
@@ -60,8 +60,14 @@ class PhoneLookupResult {
final int id;
final String? name;
final String? avatarUrl;
final int phone;
const PhoneLookupResult({required this.id, this.name, this.avatarUrl});
const PhoneLookupResult({
required this.id,
this.name,
this.avatarUrl,
this.phone = 0,
});
}
class ContactPhotos {
@@ -88,6 +94,14 @@ class ContactsModule {
final id = contact['id'];
if (id is! int) 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) {
@@ -105,6 +119,7 @@ class ContactsModule {
id: id,
name: name,
avatarUrl: contact['baseUrl'] as String?,
phone: resolvedPhone,
);
}
@@ -154,7 +169,7 @@ class ContactsModule {
row['phone'] = phone;
}
await AppDatabase.saveContacts([row]);
if (contact != null) _primeContactCache(contact);
if (contact != null) primeContactCache(contact);
revision.value++;
return CachedContact.fromDbRow(row);
}
@@ -171,7 +186,7 @@ class ContactsModule {
final contact = raw.cast<dynamic, dynamic>();
final row = _parseContact(contact, accountId);
if (row != null) rows.add(row);
_primeContactCache(contact);
primeContactCache(contact);
}
if (rows.isNotEmpty) {
@@ -200,16 +215,19 @@ class ContactsModule {
for (final raw in contacts.whereType<Map>()) {
if (raw['id'] != accountId) continue;
final contact = raw.cast<dynamic, dynamic>();
_primeContactCache(contact);
primeContactCache(contact);
return ProfileData.fromServerMap(contact);
}
return null;
}
static void _primeContactCache(Map<dynamic, dynamic> contact) {
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 = names.firstWhere(
@@ -297,6 +315,7 @@ class ContactsModule {
static Future<void> primeCacheFromDb(int accountId) async {
final contacts = await getContacts(accountId);
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;
+15 -1
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api.dart';
import '../../core/config/komet_settings.dart';
import '../../core/contacts/device_contacts_service.dart';
import '../../core/protocol/opcode_map.dart';
import '../../core/protocol/packet.dart';
import '../../core/storage/app_database.dart';
@@ -17,6 +18,7 @@ class ContactCache {
static final Map<int, String> _nameCache = {};
static final Map<int, String> _avatarCache = {};
static final Map<int, Set<String>> _optionsCache = {};
static final Map<int, int> _phoneCache = {};
static const _prefsKey = 'contact_cache_v1';
static Timer? _saveTimer;
@@ -61,7 +63,18 @@ class ContactCache {
_scheduleSave();
}
static String? get(int id) => _nameCache[id];
static void putPhone(int id, int phone) {
if (phone > 0) _phoneCache[id] = phone;
}
static String? get(int id) {
final phone = _phoneCache[id];
if (phone != null) {
final book = DeviceContactsService.nameForPhone(phone);
if (book != null && book.isNotEmpty) return book;
}
return _nameCache[id];
}
static String? getAvatar(int id) => _avatarCache[id];
static Set<String>? getOptions(int id) => _optionsCache[id];
static bool isOfficial(int id) =>
@@ -71,6 +84,7 @@ class ContactCache {
_nameCache.clear();
_avatarCache.clear();
_optionsCache.clear();
_phoneCache.clear();
_saveTimer?.cancel();
_saveTimer = null;
unawaited(_wipePersisted());
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'persisted_setting.dart';
class AppPhonebookNames {
static const prefKey = 'dev_phonebook_names';
static const bool defaultValue = true;
static final _setting = PersistedSetting<bool>(
prefKey: prefKey,
defaultValue: defaultValue,
read: (prefs, key) => prefs.getBool(key),
write: (prefs, key, value) async {
await prefs.setBool(key, value);
},
);
static ValueNotifier<bool> get current => _setting.current;
static Future<bool> load() => _setting.load();
static Future<void> save(bool value) => _setting.save(value);
}
@@ -0,0 +1,82 @@
import 'dart:io';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_phonebook_names.dart';
class DeviceContactsService {
DeviceContactsService._();
static const _grantedKey = 'phonebook_granted';
static final Map<String, String> _byLast10 = {};
static bool _loaded = false;
static bool get _supported => Platform.isAndroid || Platform.isIOS;
static String? _last10(String raw) {
final digits = raw.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length < 10) return null;
return digits.substring(digits.length - 10);
}
static String? nameForPhone(int phone) {
if (!AppPhonebookNames.current.value) return null;
if (_byLast10.isEmpty) return null;
final key = _last10(phone.toString());
if (key == null) return null;
final name = _byLast10[key];
if (name == null || name.trim().isEmpty) return null;
return name.trim();
}
static Future<void> loadFromStartup() async {
if (_loaded || !_supported) return;
if (!AppPhonebookNames.current.value) return;
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_grantedKey) != true) return;
final granted = await FlutterContacts.requestPermission(readonly: true);
if (!granted) return;
await _readBook();
}
static Future<bool> ensureLoadedInteractive() async {
if (_loaded || !_supported) return false;
if (!AppPhonebookNames.current.value) return false;
final granted = await FlutterContacts.requestPermission(readonly: true);
if (!granted) return false;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_grantedKey, true);
return _readBook();
}
static Future<bool> reload() async {
_loaded = false;
_byLast10.clear();
return ensureLoadedInteractive();
}
static Future<bool> _readBook() async {
try {
final contacts = await FlutterContacts.getContacts(
withProperties: true,
);
_byLast10.clear();
for (final contact in contacts) {
final name = contact.displayName.trim();
if (name.isEmpty) continue;
for (final phone in contact.phones) {
final key = _last10(phone.number);
if (key != null) {
_byLast10.putIfAbsent(key, () => name);
}
}
}
_loaded = true;
return _byLast10.isNotEmpty;
} catch (_) {
return false;
}
}
}
@@ -1,13 +1,16 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../backend/modules/contacts.dart';
import '../../core/config/app_commands.dart';
import '../../core/config/app_digital_id_mode.dart';
import '../../core/config/app_link_preview.dart';
import '../../core/config/app_phonebook_names.dart';
import '../../core/config/app_pranks.dart';
import '../../core/config/app_show_extra_info.dart';
import '../../core/config/app_stories.dart';
import '../../core/config/app_swipe_back_desktop.dart';
import '../../core/contacts/device_contacts_service.dart';
import '../screens/digital_id/digital_id_web_screen.dart';
import '../widgets/custom_notification.dart';
import 'debug_toggle_tile.dart';
@@ -15,6 +18,23 @@ import 'debug_toggle_tile.dart';
class DebugFeatureTogglesSection extends StatelessWidget {
const DebugFeatureTogglesSection({super.key});
Future<void> _onPhonebookNamesChanged(
BuildContext context,
bool value,
) async {
await AppPhonebookNames.save(value);
if (value) {
final ok = await DeviceContactsService.reload();
if (!ok && context.mounted) {
showCustomNotification(
context,
'Не удалось загрузить контакты телефона',
);
}
}
ContactsModule.revision.value++;
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -122,6 +142,19 @@ class DebugFeatureTogglesSection extends StatelessWidget {
onChanged: AppStories.save,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: DebugToggleTile(
icon: Symbols.contacts,
title: 'Имена из телефонной книги',
subtitle: (v) => v
? 'Имена собеседников показываются так, как записаны в '
'телефонной книге устройства'
: 'Имена показываются так, как их прислал сервер',
valueListenable: AppPhonebookNames.current,
onChanged: (v) => _onPhonebookNamesChanged(context, v),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: DebugToggleTile(
+12 -2
View File
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart';
import '../../../backend/modules/chats.dart';
import '../../../backend/modules/contacts.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../core/storage/app_database.dart';
import '../../../core/utils/debouncer.dart';
import '../../../core/utils/names.dart';
@@ -128,6 +129,11 @@ class _SearchScreenState extends State<SearchScreen> {
}
String _contactName(Map<String, dynamic> row) {
final id = row['id'];
if (id is int) {
final cached = ContactCache.get(id);
if (cached != null && cached.isNotEmpty) return cached;
}
return displayName(
row['first_name'],
row['last_name'],
@@ -135,6 +141,10 @@ class _SearchScreenState extends State<SearchScreen> {
);
}
String _phoneResultName(PhoneLookupResult result) {
return ContactCache.get(result.id) ?? result.name ?? 'User #${result.id}';
}
void _openChat(int chatId, String name, String? avatarUrl, String type) {
pushSwipeable(
context,
@@ -170,7 +180,7 @@ class _SearchScreenState extends State<SearchScreen> {
openContactDialogProfile(
context,
contactId: result.id,
name: result.name ?? 'User #${result.id}',
name: _phoneResultName(result),
avatarUrl: result.avatarUrl,
),
);
@@ -245,7 +255,7 @@ class _SearchScreenState extends State<SearchScreen> {
if (phoneResult != null) ...[
_sectionHeader(cs, 'По номеру'),
_ResultTile(
name: phoneResult.name ?? '',
name: _phoneResultName(phoneResult),
imageUrl: phoneResult.avatarUrl,
subtitle: query,
onTap: () => _openPhoneResult(phoneResult),
+121 -7
View File
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/config/debug_test.dart';
import '../../../core/contacts/device_contacts_service.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 '../../../backend/modules/contacts.dart';
import '../../../backend/modules/messages.dart' show ContactCache;
import '../../../main.dart';
import '../../../models/contact_info.dart';
import '../../widgets/komet_avatar.dart';
@@ -17,6 +19,8 @@ import '../chats/chat_info_screen.dart';
import 'nfc_exchange_sheet.dart';
import 'open_contact_profile.dart';
enum _SearchMode { phone, id }
class ContactsTab extends StatefulWidget {
const ContactsTab({super.key});
@@ -33,6 +37,12 @@ class _ContactsTabState extends State<ContactsTab> {
super.initState();
_loadContacts();
ContactsModule.revision.addListener(_loadContacts);
_loadDeviceContacts();
}
Future<void> _loadDeviceContacts() async {
final changed = await DeviceContactsService.ensureLoadedInteractive();
if (changed && mounted) setState(() {});
}
@override
@@ -115,7 +125,9 @@ class _ContactsTabState extends State<ContactsTab> {
final fullName =
'${contact.firstName}${contact.lastName != null ? ' ${contact.lastName}' : ''}'
.trim();
final nameToDisplay = fullName.isEmpty ? '+${contact.phone}' : fullName;
final book = DeviceContactsService.nameForPhone(contact.phone);
final nameToDisplay =
book ?? (fullName.isEmpty ? '+${contact.phone}' : fullName);
return SpringyTap(
child: Material(
@@ -284,6 +296,7 @@ class _SearchContactSheet extends StatefulWidget {
class _SearchContactSheetState extends State<_SearchContactSheet> {
final _controller = TextEditingController();
_SearchMode _mode = _SearchMode.phone;
bool _loading = false;
String? _error;
@@ -293,7 +306,82 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
super.dispose();
}
void _setMode(_SearchMode mode) {
if (_mode == mode || _loading) return;
setState(() {
_mode = mode;
_error = null;
});
}
Future<void> _submit() async {
if (_mode == _SearchMode.phone) {
await _submitPhone();
} else {
await _submitId();
}
}
String? _phoneCandidate(String query) {
if (!RegExp(r'^[+\d\s\-()]+$').hasMatch(query)) return null;
final digits = query.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length < 5) return null;
return query;
}
Future<void> _submitPhone() async {
final query = _phoneCandidate(_controller.text.trim());
if (query == null) {
setState(() => _error = 'Введите корректный номер телефона');
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final result = await ContactsModule.findByPhone(api, query);
if (!mounted) return;
if (result == null) {
setState(() {
_loading = false;
_error = 'Контакт с таким номером не найден';
});
return;
}
final navigator = Navigator.of(context);
final accountId = await TokenStorage.getActiveAccountId();
final existing = accountId == null
? null
: await AppDatabase.findDialogChatByParticipant(accountId, result.id);
final chatId = existing ?? ((accountId ?? 0) ^ result.id);
if (!mounted) return;
navigator.pop();
navigator.push(
MaterialPageRoute(
builder: (_) => ChatInfoScreen(
chatId: chatId,
name:
ContactCache.get(result.id) ??
result.name ??
'User #${result.id}',
imageUrl: result.avatarUrl ?? '',
chatType: 'DIALOG',
dialogPeerId: result.id,
),
),
);
} catch (e) {
if (mounted) {
setState(() {
_loading = false;
_error = 'Ошибка: $e';
});
}
}
}
Future<void> _submitId() async {
final raw = _controller.text.trim();
final id = int.tryParse(raw);
if (id == null) {
@@ -320,6 +408,7 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
}
final raw = Map<String, dynamic>.from(contacts.first as Map);
final info = ContactInfo.fromMap(raw);
ContactsModule.primeContactCache(raw);
if (!mounted) return;
final navigator = Navigator.of(context);
final accountId = await TokenStorage.getActiveAccountId();
@@ -333,7 +422,7 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
MaterialPageRoute(
builder: (_) => ChatInfoScreen(
chatId: chatId,
name: info.displayName ?? 'User #$id',
name: ContactCache.get(id) ?? info.displayName ?? 'User #$id',
imageUrl: info.avatarUrl ?? '',
chatType: 'DIALOG',
dialogPeerId: id,
@@ -374,7 +463,7 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
children: [
Expanded(
child: Text(
'Поиск по ID',
'Найти контакт',
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
@@ -388,11 +477,34 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
),
],
),
const SizedBox(height: 8),
const SizedBox(height: 12),
SegmentedButton<_SearchMode>(
segments: const [
ButtonSegment(
value: _SearchMode.phone,
label: Text('Номер'),
icon: Icon(Symbols.call, size: 18),
),
ButtonSegment(
value: _SearchMode.id,
label: Text('ID'),
icon: Icon(Symbols.tag, size: 18),
),
],
selected: {_mode},
onSelectionChanged: (s) => _setMode(s.first),
showSelectedIcon: false,
style: ButtonStyle(
visualDensity: VisualDensity.compact,
),
),
const SizedBox(height: 12),
TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.number,
keyboardType: _mode == _SearchMode.phone
? TextInputType.phone
: TextInputType.number,
enabled: !_loading,
onSubmitted: (_) => _submit(),
onChanged: (_) {
@@ -400,13 +512,15 @@ class _SearchContactSheetState extends State<_SearchContactSheet> {
},
style: TextStyle(color: cs.onSurface, fontSize: 16),
decoration: InputDecoration(
hintText: 'Введите ID контакта',
hintText: _mode == _SearchMode.phone
? 'Введите номер телефона'
: 'Введите ID контакта',
hintStyle: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 16,
),
prefixIcon: Icon(
Symbols.tag,
_mode == _SearchMode.phone ? Symbols.call : Symbols.tag,
color: cs.onSurfaceVariant,
size: 20,
),
@@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../backend/modules/contacts.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/contacts/device_contacts_service.dart';
import '../../../core/nfc/nfc_exchange_service.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart';
@@ -130,6 +131,11 @@ class _NfcExchangeSheetState extends State<NfcExchangeSheet>
String _peerName() {
final l10n = AppLocalizations.of(context)!;
final phone = _peerPhone;
if (phone != null) {
final book = DeviceContactsService.nameForPhone(phone);
if (book != null) return book;
}
return _peerInfo?.displayName ??
l10n.nfcPeerNameFallback('${_peerId ?? ''}');
}
+5
View File
@@ -34,6 +34,8 @@ import 'core/config/app_swipe_back_desktop.dart';
import 'core/config/app_pranks.dart';
import 'core/config/app_stories.dart';
import 'core/config/app_commands.dart';
import 'core/config/app_phonebook_names.dart';
import 'core/contacts/device_contacts_service.dart';
import 'core/config/app_link_preview.dart';
import 'core/config/app_media_cache.dart';
import 'core/config/app_pill_gradient.dart';
@@ -214,6 +216,7 @@ void main(List<String> args) async {
final pranksFuture = AppPranks.load();
final storiesFuture = AppStories.load();
final commandsFuture = AppCommands.load();
final phonebookNamesFuture = AppPhonebookNames.load();
final linkPreviewFuture = AppLinkPreview.load();
final cacheLimitFuture = AppMediaCacheLimit.load();
final digitalIdNativeFuture = AppDigitalIdNative.load();
@@ -269,11 +272,13 @@ void main(List<String> args) async {
pranksFuture,
storiesFuture,
commandsFuture,
phonebookNamesFuture,
linkPreviewFuture,
cacheLimitFuture,
digitalIdNativeFuture,
showExtraInfoFuture,
]);
await DeviceContactsService.loadFromStartup();
await trafficCaptureFuture;
await debugLogFuture;
runApp(
+8
View File
@@ -366,6 +366,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_contacts:
dependency: "direct main"
description:
name: flutter_contacts
sha256: "388d32cd33f16640ee169570128c933b45f3259bddbfae7a100bb49e5ffea9ae"
url: "https://pub.dev"
source: hosted
version: "1.1.9+2"
flutter_inappwebview:
dependency: "direct main"
description:
+1
View File
@@ -63,6 +63,7 @@ dependencies:
flutter_secure_storage: ^10.3.1
package_info_plus: ^9.0.1
mobile_scanner: ^7.2.0
flutter_contacts: ^1.1.9+2
cached_network_image: ^3.4.1
flutter_cache_manager: ^3.4.1
lottie: ^3.3.1