feat(contacts): отправка контактов вложением
This commit is contained in:
@@ -1613,6 +1613,26 @@ class MessagesModule {
|
||||
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 _pollMultipleFlag = 1;
|
||||
|
||||
|
||||
@@ -5785,6 +5785,9 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
onCreatePoll: _encryptionEnabled
|
||||
? () => _refuseUnencrypted('Опросы')
|
||||
: _createPoll,
|
||||
onSendContact: _encryptionEnabled
|
||||
? (_) => _refuseUnencrypted('Контакты')
|
||||
: _sendContact,
|
||||
);
|
||||
if (!mounted || !hadKeyboard) return;
|
||||
_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 {
|
||||
final draft = await showCreatePollSheet(context);
|
||||
if (draft == null || !mounted) return;
|
||||
|
||||
@@ -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<PillNavItem> _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<void> showAttachmentSheet(
|
||||
@@ -35,6 +37,7 @@ Future<void> showAttachmentSheet(
|
||||
VoidCallback? onPickFile,
|
||||
VoidCallback? onShareLocation,
|
||||
VoidCallback? onCreatePoll,
|
||||
ValueChanged<CachedContact>? onSendContact,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -48,6 +51,7 @@ Future<void> 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<CachedContact>? 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<AttachmentSheet> {
|
||||
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, {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Нет доступа к галерее';
|
||||
|
||||
|
||||
@@ -636,6 +636,10 @@
|
||||
"attachSheetNoImagesFound": "Изображений не найдено",
|
||||
"attachSheetLimitedAccessInfo": "Доступны не все фото",
|
||||
"attachSheetSectionInProgress": "Раздел в разработке",
|
||||
"attachSheetContact": "Контакт",
|
||||
"attachSheetContactSearchHint": "Поиск по контактам",
|
||||
"attachSheetNoContacts": "У вас пока нет контактов",
|
||||
"attachSheetNoContactsFound": "Контакты не найдены",
|
||||
"attachSheetNoGalleryAccessTitle": "Нет доступа к галерее",
|
||||
"attachSheetNoGalleryAccessSubtitle": "Разрешите доступ к фото, чтобы выбрать их отсюда",
|
||||
"attachSheetAllow": "Разрешить",
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user