From 131c1dc775b38b80da9e92bfd2f6b99f3faa5bfa Mon Sep 17 00:00:00 2001 From: klockky Date: Thu, 16 Jul 2026 11:49:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(contacts):=20=D0=B8=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B8=D0=B7=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BD=D0=B8=D0=B3=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=B5=D0=B7=D0=B4=D0=B5=20+=20=D1=82=D1=83=D0=BC=D0=B1?= =?UTF-8?q?=D0=BB=D0=B5=D1=80=20=D0=B2=20=D0=B4=D0=B5=D0=B2-=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 1 + ios/Runner/Info.plist | 2 + lib/backend/modules/contacts.dart | 29 +++- lib/backend/modules/messages.dart | 16 ++- lib/core/config/app_phonebook_names.dart | 23 ++++ .../contacts/device_contacts_service.dart | 82 +++++++++++ .../debug/feature_toggles_section.dart | 33 +++++ lib/frontend/screens/chats/search_screen.dart | 14 +- .../screens/contacts/contacts_tab.dart | 128 +++++++++++++++++- .../screens/contacts/nfc_exchange_sheet.dart | 6 + lib/main.dart | 5 + pubspec.lock | 8 ++ pubspec.yaml | 1 + 13 files changed, 333 insertions(+), 15 deletions(-) create mode 100644 lib/core/config/app_phonebook_names.dart create mode 100644 lib/core/contacts/device_contacts_service.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 654dcc7..b3176a3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6637b38..ee5c26e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -70,6 +70,8 @@ Доступ к галерее нужен, чтобы отправлять фото и видео в чатах. NSPhotoLibraryAddUsageDescription Доступ к галерее нужен, чтобы сохранять полученные фото и видео. + NSContactsUsageDescription + Доступ к контактам нужен, чтобы показывать имена собеседников так, как они записаны в вашей телефонной книге. CFBundleURLTypes diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 33fc381..f3e05f4 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -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(); 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()) { if (raw['id'] != accountId) continue; final contact = raw.cast(); - _primeContactCache(contact); + primeContactCache(contact); return ProfileData.fromServerMap(contact); } return null; } - static void _primeContactCache(Map contact) { + static void primeContactCache(Map 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 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; diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 3be215c..333df4f 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -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 _nameCache = {}; static final Map _avatarCache = {}; static final Map> _optionsCache = {}; + static final Map _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? 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()); diff --git a/lib/core/config/app_phonebook_names.dart b/lib/core/config/app_phonebook_names.dart new file mode 100644 index 0000000..f36d954 --- /dev/null +++ b/lib/core/config/app_phonebook_names.dart @@ -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( + prefKey: prefKey, + defaultValue: defaultValue, + read: (prefs, key) => prefs.getBool(key), + write: (prefs, key, value) async { + await prefs.setBool(key, value); + }, + ); + + static ValueNotifier get current => _setting.current; + + static Future load() => _setting.load(); + + static Future save(bool value) => _setting.save(value); +} diff --git a/lib/core/contacts/device_contacts_service.dart b/lib/core/contacts/device_contacts_service.dart new file mode 100644 index 0000000..b663eb5 --- /dev/null +++ b/lib/core/contacts/device_contacts_service.dart @@ -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 _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 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 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 reload() async { + _loaded = false; + _byLast10.clear(); + return ensureLoadedInteractive(); + } + + static Future _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; + } + } +} diff --git a/lib/frontend/debug/feature_toggles_section.dart b/lib/frontend/debug/feature_toggles_section.dart index 85ff610..55cd2cc 100644 --- a/lib/frontend/debug/feature_toggles_section.dart +++ b/lib/frontend/debug/feature_toggles_section.dart @@ -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 _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( diff --git a/lib/frontend/screens/chats/search_screen.dart b/lib/frontend/screens/chats/search_screen.dart index ed80827..2b94d6f 100644 --- a/lib/frontend/screens/chats/search_screen.dart +++ b/lib/frontend/screens/chats/search_screen.dart @@ -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 { } String _contactName(Map 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 { ); } + 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 { 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 { if (phoneResult != null) ...[ _sectionHeader(cs, 'По номеру'), _ResultTile( - name: phoneResult.name ?? '', + name: _phoneResultName(phoneResult), imageUrl: phoneResult.avatarUrl, subtitle: query, onTap: () => _openPhoneResult(phoneResult), diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index c3cbcff..530a853 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -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 { super.initState(); _loadContacts(); ContactsModule.revision.addListener(_loadContacts); + _loadDeviceContacts(); + } + + Future _loadDeviceContacts() async { + final changed = await DeviceContactsService.ensureLoadedInteractive(); + if (changed && mounted) setState(() {}); } @override @@ -115,7 +125,9 @@ class _ContactsTabState extends State { 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 _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 _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 _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.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, ), diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart index 2ff68dc..0384dd0 100644 --- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -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 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 ?? ''}'); } diff --git a/lib/main.dart b/lib/main.dart index d73dd33..944a723 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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 args) async { pranksFuture, storiesFuture, commandsFuture, + phonebookNamesFuture, linkPreviewFuture, cacheLimitFuture, digitalIdNativeFuture, showExtraInfoFuture, ]); + await DeviceContactsService.loadFromStartup(); await trafficCaptureFuture; await debugLogFuture; runApp( diff --git a/pubspec.lock b/pubspec.lock index 66872a1..68fa50c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index f4cc93e..30f0c07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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