From 90ca80431d1b96a3a7cf27932cdc541b5b04b338 Mon Sep 17 00:00:00 2001 From: klockky Date: Sun, 26 Jul 2026 15:47:34 +0300 Subject: [PATCH] =?UTF-8?q?feat(contacts):=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 20 ++ lib/frontend/screens/chats/chat_screen.dart | 19 ++ .../widgets/attachment/attachment_sheet.dart | 19 +- .../attachment/contact_picker_page.dart | 215 ++++++++++++++++++ lib/l10n/app_en.arb | 4 + lib/l10n/app_localizations.dart | 24 ++ lib/l10n/app_localizations_en.dart | 12 + lib/l10n/app_localizations_ru.dart | 12 + lib/l10n/app_ru.arb | 4 + pubspec.yaml | 3 +- 10 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 lib/frontend/widgets/attachment/contact_picker_page.dart diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 881dc5a..6d7806e 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -1613,6 +1613,26 @@ class MessagesModule { return _sentMessageMap(response); } + Future?> sendContactMessage( + int chatId, + int contactId, { + bool notify = true, + }) async { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'attaches': [ + {'_type': 'CONTACT', 'contactId': contactId}, + ], + }, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + return _sentMessageMap(response); + } + static const int _pollAnonymousFlag = 4; static const int _pollMultipleFlag = 1; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index c70ff5a..f2bdf75 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -5785,6 +5785,9 @@ class _ChatScreenState extends State onCreatePoll: _encryptionEnabled ? () => _refuseUnencrypted('Опросы') : _createPoll, + onSendContact: _encryptionEnabled + ? (_) => _refuseUnencrypted('Контакты') + : _sendContact, ); if (!mounted || !hadKeyboard) return; _messageFocusNode.requestFocus(); @@ -6190,6 +6193,22 @@ class _ChatScreenState extends State } } + Future _sendContact(CachedContact contact) async { + final last = contact.lastName; + final fullName = (last != null && last.isNotEmpty) + ? '${contact.firstName} $last' + : contact.firstName; + await _sendAttachMessage([ + ContactAttachment( + contactId: contact.id, + firstName: contact.firstName, + lastName: last, + name: fullName, + photoUrl: contact.baseUrl, + ), + ], () => messagesModule.sendContactMessage(widget.chatId, contact.id)); + } + Future _createPoll() async { final draft = await showCreatePollSheet(context); if (draft == null || !mounted) return; diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 4856c19..fbb71c5 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -4,11 +4,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:komet/backend/modules/contacts.dart'; import 'package:komet/core/config/app_frost.dart'; import 'package:komet/core/config/app_nav_pill_style.dart'; import 'package:komet/core/config/app_visual_style.dart'; import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/core/utils/format.dart'; +import 'package:komet/frontend/widgets/attachment/contact_picker_page.dart'; import 'package:komet/frontend/widgets/attachment/media_preview_screen.dart'; import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; @@ -25,7 +27,7 @@ List _buildNavItems(AppLocalizations l10n) => [ PillNavItem(icon: Symbols.description, label: l10n.scheduledAttachFile), PillNavItem(icon: Symbols.location_on, label: l10n.scheduledAttachLocation), PillNavItem(icon: Symbols.bar_chart, label: l10n.attachSheetPoll), - PillNavItem(icon: Symbols.person, label: l10n.nfcPeerFirstNameFallback), + PillNavItem(icon: Symbols.person, label: l10n.attachSheetContact), ]; Future showAttachmentSheet( @@ -35,6 +37,7 @@ Future showAttachmentSheet( VoidCallback? onPickFile, VoidCallback? onShareLocation, VoidCallback? onCreatePoll, + ValueChanged? onSendContact, }) { return showModalBottomSheet( context: context, @@ -48,6 +51,7 @@ Future showAttachmentSheet( onPickFile: onPickFile, onShareLocation: onShareLocation, onCreatePoll: onCreatePoll, + onSendContact: onSendContact, ), ); } @@ -58,6 +62,7 @@ class AttachmentSheet extends StatefulWidget { final VoidCallback? onPickFile; final VoidCallback? onShareLocation; final VoidCallback? onCreatePoll; + final ValueChanged? onSendContact; const AttachmentSheet({ super.key, @@ -66,6 +71,7 @@ class AttachmentSheet extends StatefulWidget { this.onPickFile, this.onShareLocation, this.onCreatePoll, + this.onSendContact, }); @override @@ -327,11 +333,20 @@ class _AttachmentSheetState extends State { buttonLabel: l10n.attachSheetCreatePoll, onTap: widget.onCreatePoll, ), - _buildPlaceholderPage(cs, bottomReserve), + _buildContactPage(cs, bottomReserve), ], ); } + Widget _buildContactPage(ColorScheme cs, double bottomReserve) { + final onSendContact = widget.onSendContact; + if (onSendContact == null) return _buildPlaceholderPage(cs, bottomReserve); + return ContactPickerPage( + bottomReserve: bottomReserve, + onPick: onSendContact, + ); + } + Widget _buildActionPage( ColorScheme cs, double bottomReserve, { diff --git a/lib/frontend/widgets/attachment/contact_picker_page.dart b/lib/frontend/widgets/attachment/contact_picker_page.dart new file mode 100644 index 0000000..94d55a0 --- /dev/null +++ b/lib/frontend/widgets/attachment/contact_picker_page.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/backend/modules/contacts.dart'; +import 'package:komet/core/config/debug_test.dart'; +import 'package:komet/core/contacts/device_contacts_service.dart'; +import 'package:komet/core/storage/app_database.dart'; +import 'package:komet/frontend/widgets/komet_avatar.dart'; +import 'package:komet/frontend/widgets/small_spinner.dart'; +import 'package:komet/frontend/widgets/springy_tap.dart'; +import 'package:komet/l10n/app_localizations.dart'; + +class ContactPickerPage extends StatefulWidget { + final double bottomReserve; + final ValueChanged onPick; + + const ContactPickerPage({ + super.key, + required this.bottomReserve, + required this.onPick, + }); + + @override + State createState() => _ContactPickerPageState(); +} + +class _ContactPickerPageState extends State { + final TextEditingController _queryCtrl = TextEditingController(); + + List _contacts = const []; + String _query = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _queryCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + final loaded = await _fetch(); + if (!mounted) return; + setState(() { + _contacts = loaded; + _loading = false; + }); + } + + Future> _fetch() async { + if (DebugTest.enabled) { + return ContactsModule.debugContacts() + ..sort((a, b) => a.firstName.compareTo(b.firstName)); + } + final profile = await AppDatabase.loadActiveProfile(); + if (profile == null) return const []; + final contacts = await ContactsModule.getContacts(profile.id); + contacts.sort((a, b) => _displayName(a).compareTo(_displayName(b))); + return contacts; + } + + String _displayName(CachedContact contact) { + final book = DeviceContactsService.nameForPhone(contact.phone); + if (book != null && book.isNotEmpty) return book; + final last = contact.lastName; + final full = (last != null && last.isNotEmpty) + ? '${contact.firstName} $last' + : contact.firstName; + final trimmed = full.trim(); + return trimmed.isEmpty ? '+${contact.phone}' : trimmed; + } + + List get _visible { + final query = _query.trim().toLowerCase(); + if (query.isEmpty) return _contacts; + return _contacts + .where( + (c) => + _displayName(c).toLowerCase().contains(query) || + c.phone.toString().contains(query), + ) + .toList(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + + return Padding( + padding: EdgeInsets.only(bottom: widget.bottomReserve), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: TextField( + controller: _queryCtrl, + onChanged: (value) => setState(() => _query = value), + style: TextStyle(color: cs.onSurface, fontSize: 15), + decoration: InputDecoration( + hintText: l10n.attachSheetContactSearchHint, + hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + prefixIcon: Icon( + Symbols.search, + color: cs.onSurfaceVariant, + size: 20, + ), + isDense: true, + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + ), + ), + ), + Expanded(child: _buildBody(cs, l10n)), + ], + ), + ); + } + + Widget _buildBody(ColorScheme cs, AppLocalizations l10n) { + if (_loading) { + return Center(child: SmallSpinner(size: 36, color: cs.primary)); + } + final visible = _visible; + if (visible.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.person_off, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 12), + Text( + _contacts.isEmpty + ? l10n.attachSheetNoContacts + : l10n.attachSheetNoContactsFound, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), + ], + ), + ), + ); + } + + return ListView.builder( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.only(bottom: 8), + itemCount: visible.length, + itemBuilder: (context, index) { + final contact = visible[index]; + return _buildTile(cs, contact); + }, + ); + } + + Widget _buildTile(ColorScheme cs, CachedContact contact) { + final name = _displayName(contact); + return SpringyTap( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.of(context).pop(); + widget.onPick(contact); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + KometAvatar(name: name, imageUrl: contact.baseUrl, size: 44), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + '+${contact.phone}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 03251f7..caebf37 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -847,6 +847,10 @@ "attachSheetNoImagesFound": "No images found", "attachSheetLimitedAccessInfo": "Not all photos are accessible", "attachSheetSectionInProgress": "Section under development", + "attachSheetContact": "Contact", + "attachSheetContactSearchHint": "Search contacts", + "attachSheetNoContacts": "You have no contacts yet", + "attachSheetNoContactsFound": "No contacts found", "attachSheetNoGalleryAccessTitle": "No access to the gallery", "attachSheetNoGalleryAccessSubtitle": "Allow access to photos to pick them from here", "attachSheetAllow": "Allow", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2e33b98..47ad6a8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3764,6 +3764,30 @@ abstract class AppLocalizations { /// **'Section under development'** String get attachSheetSectionInProgress; + /// No description provided for @attachSheetContact. + /// + /// In en, this message translates to: + /// **'Contact'** + String get attachSheetContact; + + /// No description provided for @attachSheetContactSearchHint. + /// + /// In en, this message translates to: + /// **'Search contacts'** + String get attachSheetContactSearchHint; + + /// No description provided for @attachSheetNoContacts. + /// + /// In en, this message translates to: + /// **'You have no contacts yet'** + String get attachSheetNoContacts; + + /// No description provided for @attachSheetNoContactsFound. + /// + /// In en, this message translates to: + /// **'No contacts found'** + String get attachSheetNoContactsFound; + /// No description provided for @attachSheetNoGalleryAccessTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 53bf872..6c59d86 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1956,6 +1956,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get attachSheetSectionInProgress => 'Section under development'; + @override + String get attachSheetContact => 'Contact'; + + @override + String get attachSheetContactSearchHint => 'Search contacts'; + + @override + String get attachSheetNoContacts => 'You have no contacts yet'; + + @override + String get attachSheetNoContactsFound => 'No contacts found'; + @override String get attachSheetNoGalleryAccessTitle => 'No access to the gallery'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 035e282..f22d8b7 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1970,6 +1970,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get attachSheetSectionInProgress => 'Раздел в разработке'; + @override + String get attachSheetContact => 'Контакт'; + + @override + String get attachSheetContactSearchHint => 'Поиск по контактам'; + + @override + String get attachSheetNoContacts => 'У вас пока нет контактов'; + + @override + String get attachSheetNoContactsFound => 'Контакты не найдены'; + @override String get attachSheetNoGalleryAccessTitle => 'Нет доступа к галерее'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6c520d5..c0d68a1 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -636,6 +636,10 @@ "attachSheetNoImagesFound": "Изображений не найдено", "attachSheetLimitedAccessInfo": "Доступны не все фото", "attachSheetSectionInProgress": "Раздел в разработке", + "attachSheetContact": "Контакт", + "attachSheetContactSearchHint": "Поиск по контактам", + "attachSheetNoContacts": "У вас пока нет контактов", + "attachSheetNoContactsFound": "Контакты не найдены", "attachSheetNoGalleryAccessTitle": "Нет доступа к галерее", "attachSheetNoGalleryAccessSubtitle": "Разрешите доступ к фото, чтобы выбрать их отсюда", "attachSheetAllow": "Разрешить", diff --git a/pubspec.yaml b/pubspec.yaml index fbbaf50..f70a4c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,8 +35,7 @@ dependencies: intl: any # Rust networking core (kolibri) — FFI plugin, replaces the Dart transport. - # DEV: local path for fast iteration; re-pin to third_party/kolibri submodule - # (path: third_party/kolibri/kolibri-dart) before merge. + # Pinned to the third_party/kolibri submodule. kolibri: path: third_party/kolibri/kolibri-dart