feat(contacts): отправка контактов вложением

This commit is contained in:
klockky
2026-07-26 15:49:15 +03:00
parent b3f4418890
commit 90ca80431d
10 changed files with 328 additions and 4 deletions
+20
View File
@@ -1613,6 +1613,26 @@ class MessagesModule {
return _sentMessageMap(response); return _sentMessageMap(response);
} }
Future<Map<String, dynamic>?> 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 _pollAnonymousFlag = 4;
static const int _pollMultipleFlag = 1; static const int _pollMultipleFlag = 1;
@@ -5785,6 +5785,9 @@ class _ChatScreenState extends State<ChatScreen>
onCreatePoll: _encryptionEnabled onCreatePoll: _encryptionEnabled
? () => _refuseUnencrypted('Опросы') ? () => _refuseUnencrypted('Опросы')
: _createPoll, : _createPoll,
onSendContact: _encryptionEnabled
? (_) => _refuseUnencrypted('Контакты')
: _sendContact,
); );
if (!mounted || !hadKeyboard) return; if (!mounted || !hadKeyboard) return;
_messageFocusNode.requestFocus(); _messageFocusNode.requestFocus();
@@ -6190,6 +6193,22 @@ class _ChatScreenState extends State<ChatScreen>
} }
} }
Future<void> _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<void> _createPoll() async { Future<void> _createPoll() async {
final draft = await showCreatePollSheet(context); final draft = await showCreatePollSheet(context);
if (draft == null || !mounted) return; if (draft == null || !mounted) return;
@@ -4,11 +4,13 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.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_frost.dart';
import 'package:komet/core/config/app_nav_pill_style.dart'; import 'package:komet/core/config/app_nav_pill_style.dart';
import 'package:komet/core/config/app_visual_style.dart'; import 'package:komet/core/config/app_visual_style.dart';
import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/core/media/gallery_source.dart';
import 'package:komet/core/utils/format.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/media_preview_screen.dart';
import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; import 'package:komet/frontend/widgets/attachment/photo_editor.dart';
import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/custom_notification.dart';
@@ -25,7 +27,7 @@ List<PillNavItem> _buildNavItems(AppLocalizations l10n) => [
PillNavItem(icon: Symbols.description, label: l10n.scheduledAttachFile), PillNavItem(icon: Symbols.description, label: l10n.scheduledAttachFile),
PillNavItem(icon: Symbols.location_on, label: l10n.scheduledAttachLocation), PillNavItem(icon: Symbols.location_on, label: l10n.scheduledAttachLocation),
PillNavItem(icon: Symbols.bar_chart, label: l10n.attachSheetPoll), PillNavItem(icon: Symbols.bar_chart, label: l10n.attachSheetPoll),
PillNavItem(icon: Symbols.person, label: l10n.nfcPeerFirstNameFallback), PillNavItem(icon: Symbols.person, label: l10n.attachSheetContact),
]; ];
Future<void> showAttachmentSheet( Future<void> showAttachmentSheet(
@@ -35,6 +37,7 @@ Future<void> showAttachmentSheet(
VoidCallback? onPickFile, VoidCallback? onPickFile,
VoidCallback? onShareLocation, VoidCallback? onShareLocation,
VoidCallback? onCreatePoll, VoidCallback? onCreatePoll,
ValueChanged<CachedContact>? onSendContact,
}) { }) {
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
@@ -48,6 +51,7 @@ Future<void> showAttachmentSheet(
onPickFile: onPickFile, onPickFile: onPickFile,
onShareLocation: onShareLocation, onShareLocation: onShareLocation,
onCreatePoll: onCreatePoll, onCreatePoll: onCreatePoll,
onSendContact: onSendContact,
), ),
); );
} }
@@ -58,6 +62,7 @@ class AttachmentSheet extends StatefulWidget {
final VoidCallback? onPickFile; final VoidCallback? onPickFile;
final VoidCallback? onShareLocation; final VoidCallback? onShareLocation;
final VoidCallback? onCreatePoll; final VoidCallback? onCreatePoll;
final ValueChanged<CachedContact>? onSendContact;
const AttachmentSheet({ const AttachmentSheet({
super.key, super.key,
@@ -66,6 +71,7 @@ class AttachmentSheet extends StatefulWidget {
this.onPickFile, this.onPickFile,
this.onShareLocation, this.onShareLocation,
this.onCreatePoll, this.onCreatePoll,
this.onSendContact,
}); });
@override @override
@@ -327,11 +333,20 @@ class _AttachmentSheetState extends State<AttachmentSheet> {
buttonLabel: l10n.attachSheetCreatePoll, buttonLabel: l10n.attachSheetCreatePoll,
onTap: widget.onCreatePoll, 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( Widget _buildActionPage(
ColorScheme cs, ColorScheme cs,
double bottomReserve, { double bottomReserve, {
@@ -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<CachedContact> onPick;
const ContactPickerPage({
super.key,
required this.bottomReserve,
required this.onPick,
});
@override
State<ContactPickerPage> createState() => _ContactPickerPageState();
}
class _ContactPickerPageState extends State<ContactPickerPage> {
final TextEditingController _queryCtrl = TextEditingController();
List<CachedContact> _contacts = const [];
String _query = '';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_queryCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
final loaded = await _fetch();
if (!mounted) return;
setState(() {
_contacts = loaded;
_loading = false;
});
}
Future<List<CachedContact>> _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<CachedContact> 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,
),
),
],
),
),
],
),
),
),
),
);
}
}
+4
View File
@@ -847,6 +847,10 @@
"attachSheetNoImagesFound": "No images found", "attachSheetNoImagesFound": "No images found",
"attachSheetLimitedAccessInfo": "Not all photos are accessible", "attachSheetLimitedAccessInfo": "Not all photos are accessible",
"attachSheetSectionInProgress": "Section under development", "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", "attachSheetNoGalleryAccessTitle": "No access to the gallery",
"attachSheetNoGalleryAccessSubtitle": "Allow access to photos to pick them from here", "attachSheetNoGalleryAccessSubtitle": "Allow access to photos to pick them from here",
"attachSheetAllow": "Allow", "attachSheetAllow": "Allow",
+24
View File
@@ -3764,6 +3764,30 @@ abstract class AppLocalizations {
/// **'Section under development'** /// **'Section under development'**
String get attachSheetSectionInProgress; 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. /// No description provided for @attachSheetNoGalleryAccessTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+12
View File
@@ -1956,6 +1956,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get attachSheetSectionInProgress => 'Section under development'; 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 @override
String get attachSheetNoGalleryAccessTitle => 'No access to the gallery'; String get attachSheetNoGalleryAccessTitle => 'No access to the gallery';
+12
View File
@@ -1970,6 +1970,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get attachSheetSectionInProgress => 'Раздел в разработке'; String get attachSheetSectionInProgress => 'Раздел в разработке';
@override
String get attachSheetContact => 'Контакт';
@override
String get attachSheetContactSearchHint => 'Поиск по контактам';
@override
String get attachSheetNoContacts => 'У вас пока нет контактов';
@override
String get attachSheetNoContactsFound => 'Контакты не найдены';
@override @override
String get attachSheetNoGalleryAccessTitle => 'Нет доступа к галерее'; String get attachSheetNoGalleryAccessTitle => 'Нет доступа к галерее';
+4
View File
@@ -636,6 +636,10 @@
"attachSheetNoImagesFound": "Изображений не найдено", "attachSheetNoImagesFound": "Изображений не найдено",
"attachSheetLimitedAccessInfo": "Доступны не все фото", "attachSheetLimitedAccessInfo": "Доступны не все фото",
"attachSheetSectionInProgress": "Раздел в разработке", "attachSheetSectionInProgress": "Раздел в разработке",
"attachSheetContact": "Контакт",
"attachSheetContactSearchHint": "Поиск по контактам",
"attachSheetNoContacts": "У вас пока нет контактов",
"attachSheetNoContactsFound": "Контакты не найдены",
"attachSheetNoGalleryAccessTitle": "Нет доступа к галерее", "attachSheetNoGalleryAccessTitle": "Нет доступа к галерее",
"attachSheetNoGalleryAccessSubtitle": "Разрешите доступ к фото, чтобы выбрать их отсюда", "attachSheetNoGalleryAccessSubtitle": "Разрешите доступ к фото, чтобы выбрать их отсюда",
"attachSheetAllow": "Разрешить", "attachSheetAllow": "Разрешить",
+1 -2
View File
@@ -35,8 +35,7 @@ dependencies:
intl: any intl: any
# Rust networking core (kolibri) — FFI plugin, replaces the Dart transport. # Rust networking core (kolibri) — FFI plugin, replaces the Dart transport.
# DEV: local path for fast iteration; re-pin to third_party/kolibri submodule # Pinned to the third_party/kolibri submodule.
# (path: third_party/kolibri/kolibri-dart) before merge.
kolibri: kolibri:
path: third_party/kolibri/kolibri-dart path: third_party/kolibri/kolibri-dart