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,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 ?? ''}');
}