feat: нормальная работа с группами

This commit is contained in:
Jganenokk
2026-07-25 15:30:07 +07:00
parent 8e55675c5e
commit 20503c9b1a
12 changed files with 1157 additions and 88 deletions
+141
View File
@@ -13,6 +13,7 @@ import '../../core/storage/app_database.dart';
import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/text_format.dart';
import '../../models/contact_info.dart';
import '../api.dart';
import 'chat_parsing.dart';
import 'chat_preview.dart';
@@ -303,6 +304,33 @@ class ChatSearchHit {
});
}
class ChatMemberEntry {
final int id;
final String? name;
final String? avatarUrl;
final int? seenTime;
final int presenceStatus;
final bool blocked;
const ChatMemberEntry({
required this.id,
this.name,
this.avatarUrl,
this.seenTime,
required this.presenceStatus,
this.blocked = false,
});
bool get isOnline => presenceStatus == 1;
}
class ChatMembersPage {
final List<ChatMemberEntry> members;
final int marker;
const ChatMembersPage({required this.members, required this.marker});
}
class MessageSearchHit {
final int chatId;
final String? messageId;
@@ -1686,6 +1714,119 @@ class ChatsModule {
}
}
Future<bool> addMembers(
Api api, {
required int chatId,
required List<int> userIds,
bool showHistory = true,
}) async {
if (userIds.isEmpty) return false;
try {
final packet = await api.sendRequest(Opcode.chatMembersUpdate, {
'chatId': chatId,
'userIds': userIds,
'showHistory': showHistory,
'operation': 'add',
});
if (!packet.isOk) {
logger.w('addMembers $chatId: ${messageFromErrorPayload(packet.payload)}');
return false;
}
final data = packet.payload;
final chat = data is Map ? data['chat'] : null;
if (chat is Map) {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) {
await cacheServerChat(chat.cast<dynamic, dynamic>(), accountId);
}
}
return true;
} on PacketError catch (e) {
logger.w('addMembers $chatId: ${e.message}');
return false;
} catch (e) {
logger.w('addMembers $chatId: $e');
return false;
}
}
Future<ChatMembersPage?> getChatMembers(
Api api,
int chatId, {
int marker = 0,
int count = 50,
}) async {
try {
final packet = await api.sendRequest(Opcode.chatMembers, {
'type': 'MEMBER',
'marker': marker,
'chatId': chatId,
'count': count,
});
if (!packet.isOk) return null;
final payload = packet.payload;
if (payload is! Map) return null;
final entries = <ChatMemberEntry>[];
final presenceById = <int, Map<String, dynamic>>{};
final rawMembers = payload['members'];
if (rawMembers is List) {
for (final m in rawMembers.whereType<Map>()) {
final contact = m['contact'];
if (contact is! Map) continue;
final id = contact['id'];
if (id is! int) continue;
final info = ContactInfo.fromMap(Map<String, dynamic>.from(contact));
final name = info.displayName;
if (name != null && name.isNotEmpty) ContactCache.put(id, name);
final avatar = info.avatarUrl;
if (avatar != null && avatar.isNotEmpty) {
ContactCache.putAvatar(id, avatar);
}
final phone = contact['phone'];
if (phone is int && phone > 0) ContactCache.putPhone(id, phone);
ContactInfoFetch.putContact(id, contact.cast<dynamic, dynamic>());
final presence = m['presence'];
var status = 0;
int? seen;
if (presence is Map) {
final p = Map<String, dynamic>.from(presence);
presenceById[id] = p;
status = (p['status'] as int?) ?? 0;
final s = p['seen'];
seen = s is int ? s : null;
}
entries.add(
ChatMemberEntry(
id: id,
name: name,
avatarUrl: avatar,
seenTime: seen,
presenceStatus: status,
blocked: info.isDeleted,
),
);
}
}
if (presenceById.isNotEmpty) PresenceFetch.primeAll(presenceById);
final next = payload['marker'];
return ChatMembersPage(
members: entries,
marker: next is int ? next : marker,
);
} on PacketError catch (e) {
logger.w('getChatMembers $chatId: ${e.message}');
return null;
} catch (e) {
logger.w('getChatMembers $chatId: $e');
return null;
}
}
Future<List<CachedChat>> refreshChats(Api api, List<int> chatIds) async {
if (chatIds.isEmpty) return const [];
try {
+40
View File
@@ -113,6 +113,46 @@ class ContactInfoFetch {
_cache.putValue(id, ContactInfo.fromMap(Map<String, dynamic>.from(contact)));
}
static Future<Map<int, ContactInfo>> getMany(
List<int> ids, {
bool forceRefresh = false,
}) async {
final result = <int, ContactInfo>{};
final missing = <int>[];
for (final id in ids) {
if (!forceRefresh) {
final cached = _cache.peek(id);
if (cached != null) {
result[id] = cached;
continue;
}
}
missing.add(id);
}
if (missing.isEmpty) return result;
final api = _api;
if (api == null || api.state != SessionState.online) return result;
try {
final resp = await api.sendRequest(Opcode.contactInfo, {
'contactIds': missing,
});
final data = resp.payload;
final contacts = data is Map ? data['contacts'] : null;
if (contacts is List) {
final now = DateTime.now();
for (final c in contacts.whereType<Map>()) {
final id = c['id'];
if (id is! int) continue;
final info = ContactInfo.fromMap(Map<String, dynamic>.from(c));
_cache.putValue(id, info, at: now);
result[id] = info;
}
}
} catch (_) {}
return result;
}
static Future<ContactInfo?> _fetch(int id) async {
final api = _api;
if (api == null || api.state != SessionState.online) return null;
+11
View File
@@ -883,6 +883,17 @@ class AppDatabase {
);
}
static Future<List<int>> loadContactIds(int accountId) async {
final db = await _instance;
final rows = await db.query(
'contacts',
columns: ['id'],
where: 'account_id = ?',
whereArgs: [accountId],
);
return [for (final r in rows) r['id'] as int];
}
static Future<Map<String, dynamic>?> loadContact(
int accountId,
int id,
+352 -88
View File
@@ -20,24 +20,39 @@ import '../../widgets/connection_status.dart';
import '../../widgets/glossy_pill.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/swipe_route.dart';
import '../../../backend/modules/chats.dart';
import '../contacts/open_contact_profile.dart';
import 'chat_screen.dart';
import 'group_invite_sheets.dart';
class _MemberInfo {
final int id;
final String? name;
final String? avatarUrl;
final bool isAdmin;
final bool isOwner;
final bool isMe;
final String? alias;
final int? seenTime;
final bool isOnline;
final int presenceStatus;
final bool blocked;
final bool isContact;
const _MemberInfo({
required this.id,
this.name,
this.avatarUrl,
required this.isAdmin,
required this.isOwner,
required this.isMe,
this.alias,
this.seenTime,
required this.isOnline,
required this.presenceStatus,
this.blocked = false,
this.isContact = false,
});
bool get isOnline => presenceStatus == 1;
}
enum ChatInfoTab { media }
@@ -88,8 +103,16 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
int _presenceStatus = 0;
bool _isBot = false;
List<_MemberInfo> _members = [];
int _onlineCount = 0;
final List<_MemberInfo> _members = [];
final List<_MemberInfo> _owners = [];
final List<_MemberInfo> _admins = [];
final List<_MemberInfo> _contactMembers = [];
final List<_MemberInfo> _otherMembers = [];
final Set<int> _seenMemberIds = {};
Set<int> _contactIds = {};
int _memberMarker = 0;
bool _membersLoading = false;
bool _membersEnd = false;
int _mediaChatId = 0;
String? _anchorMsgId;
@@ -97,6 +120,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
@override
void initState() {
super.initState();
_bodyScrollController.addListener(_onBodyScroll);
_load();
}
@@ -207,34 +231,9 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
setState(() => _isLoading = false);
return;
} else if (widget.chatType == 'CHAT') {
final chatInfo = _chatInfo!;
final memberIds = chatInfo.participantIds;
Map<int, Map<String, dynamic>> presenceMap = {};
if (memberIds.isNotEmpty) {
presenceMap = await PresenceFetch.getMany(memberIds);
}
_onlineCount = 0;
_members = memberIds.map((id) {
final pres = presenceMap[id];
final online = (pres?['status'] as int?) == 1;
if (online) _onlineCount++;
return _MemberInfo(
id: id,
isAdmin: chatInfo.isAdmin(id),
isOwner: chatInfo.isOwner(id),
isMe: id == _myId,
seenTime: pres?['seen'] as int?,
isOnline: online,
);
}).toList();
_members.sort((a, b) {
if (a.isMe != b.isMe) return a.isMe ? -1 : 1;
if (a.isOnline != b.isOnline) return a.isOnline ? -1 : 1;
return (b.seenTime ?? 0).compareTo(a.seenTime ?? 0);
});
_contactIds = (await AppDatabase.loadContactIds(_myId)).toSet();
await _loadLeaders();
await _fetchMembersPage(initial: true);
}
if (mounted) {
@@ -253,6 +252,177 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
return _tabs.contains(media) ? media : null;
}
_MemberInfo _memberFrom(ChatMemberEntry e) => _MemberInfo(
id: e.id,
name: e.name,
avatarUrl: e.avatarUrl,
isAdmin: _chatInfo?.isAdmin(e.id) ?? false,
isOwner: _chatInfo?.isOwner(e.id) ?? false,
isMe: e.id == _myId,
alias: _chatInfo?.adminAlias(e.id),
seenTime: e.seenTime,
presenceStatus: e.presenceStatus,
blocked: e.blocked,
isContact: _contactIds.contains(e.id),
);
Future<void> _loadLeaders() async {
final info = _chatInfo;
if (info == null) return;
final owner = info.owner;
final leaderIds = <int>[
if (owner != null && owner != 0) owner,
for (final a in info.adminIds)
if (a != owner) a,
];
if (leaderIds.isEmpty) return;
final contacts = await ContactInfoFetch.getMany(leaderIds);
final presence = await PresenceFetch.getMany(leaderIds);
if (!mounted) return;
for (final id in leaderIds) {
if (!_seenMemberIds.add(id)) continue;
final c = contacts[id];
final pres = presence[id];
_addMember(
_MemberInfo(
id: id,
name: c?.displayName ?? ContactCache.get(id),
avatarUrl: c?.avatarUrl ?? ContactCache.getAvatar(id),
isAdmin: info.isAdmin(id),
isOwner: info.isOwner(id),
isMe: id == _myId,
alias: info.adminAlias(id),
seenTime: pres?['seen'] as int?,
presenceStatus: (pres?['status'] as int?) ?? 0,
blocked: c?.isDeleted ?? false,
isContact: _contactIds.contains(id),
),
);
}
_rebuildMembers();
}
int _memberRank(_MemberInfo m) {
if (m.isOwner) return 0;
if (m.isAdmin) return 1;
if (m.isContact) return 2;
return 3;
}
void _addMember(_MemberInfo m) {
switch (_memberRank(m)) {
case 0:
_owners.add(m);
case 1:
_admins.add(m);
case 2:
_contactMembers.add(m);
default:
_otherMembers.add(m);
}
}
void _rebuildMembers() {
_members
..clear()
..addAll(_owners)
..addAll(_admins)
..addAll(_contactMembers)
..addAll(_otherMembers);
}
Future<void> _fetchMembersPage({bool initial = false}) async {
if (_membersLoading || _membersEnd) return;
_membersLoading = true;
if (!initial && mounted) setState(() {});
final page = await chats.getChatMembers(
api,
widget.chatId,
marker: _memberMarker,
);
_membersLoading = false;
if (!mounted) return;
if (page == null) {
if (!initial) setState(() {});
return;
}
var added = 0;
for (final e in page.members) {
if (_seenMemberIds.add(e.id)) {
_addMember(_memberFrom(e));
added++;
}
}
if (added > 0) _rebuildMembers();
final total = _chatInfo?.participantsCount;
if (page.members.isEmpty ||
added == 0 ||
page.marker == _memberMarker ||
(total != null && _members.length >= total)) {
_membersEnd = true;
}
_memberMarker = page.marker;
if (!initial) setState(() {});
}
void _onBodyScroll() {
if (!mounted || widget.chatType != 'CHAT') return;
if (_membersLoading || _membersEnd) return;
if (_selectedTab != AppLocalizations.of(context)!.chatInfoTabMembers) return;
final pos = _bodyScrollController.position;
if (pos.pixels >= pos.maxScrollExtent - 400) {
_fetchMembersPage();
}
}
String? get _inviteLink {
final link = _chatInfo?.link;
return (link != null && link.isNotEmpty) ? link : null;
}
Future<void> _openAddMembers() async {
final exclude = {_myId, ..._members.map((m) => m.id)};
final added = await showAddMembersSheet(
context,
chatId: widget.chatId,
excludeIds: exclude,
);
if (added == true && mounted) await _refreshMembers();
}
void _openInviteLink(String link) {
showInviteLinkSheet(
context,
link: link,
title: widget.name,
avatarUrl: widget.imageUrl,
);
}
Future<void> _refreshMembers() async {
_contactMembers.clear();
_otherMembers.clear();
_seenMemberIds
..clear()
..addAll(_owners.map((m) => m.id))
..addAll(_admins.map((m) => m.id));
_memberMarker = 0;
_membersEnd = false;
_membersLoading = false;
_rebuildMembers();
if (mounted) setState(() {});
await _fetchMembersPage(initial: true);
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -326,6 +496,8 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
bool get _isContact => _localContact != null;
bool get _peerDeleted => _contactData?.isDeleted ?? false;
String _joinName(String first, String last) => last.trim().isEmpty
? first.trim()
: '${first.trim()} ${last.trim()}';
@@ -420,8 +592,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
);
final custom = _customName;
final real = _realName;
final hasToggle =
widget.chatType == 'DIALOG' && real != null && real != custom;
final hasToggle = _isContact && real != null && real != custom;
final nameSwap = AnimatedTextSwap(
showAlternate: _showRealName,
@@ -463,6 +634,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
final l10n = AppLocalizations.of(context)!;
switch (widget.chatType) {
case 'DIALOG':
if (_peerDeleted) return l10n.chatInfoMemberDeleted;
if (_isBot) return l10n.contactProfileBot;
if (_isOnline) return l10n.contactProfileOnline;
if (_presenceStatus == 2 || _presenceStatus == 3) return l10n.contactProfileRecentlyActive;
@@ -472,9 +644,6 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
return '';
case 'CHAT':
final total = _chatInfo?.participantsCount ?? _members.length;
if (_onlineCount > 0) {
return l10n.chatInfoOnlineOfTotal('$_onlineCount', '$total');
}
return '$total ${pluralRu(total, 'участник', 'участника', 'участников')}';
case 'CHANNEL':
final count = _chatInfo?.participantsCount ?? 0;
@@ -1026,13 +1195,64 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
),
child: Column(
children: [
_memberAction(cs, Icons.person_add, l10n.chatInfoAddMember, () {}),
_memberAction(
cs,
Icons.person_add,
l10n.chatInfoAddMember,
_openAddMembers,
),
if (_inviteLink != null) ...[
_listDivider(cs),
_memberAction(
cs,
Icons.link,
l10n.chatInfoInviteByLink,
() => _openInviteLink(_inviteLink!),
),
],
..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]),
if (_membersLoading || !_membersEnd) ...[
_listDivider(cs),
_membersFooter(cs),
],
],
),
);
}
Widget _membersFooter(ColorScheme cs) {
if (_membersLoading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
final l10n = AppLocalizations.of(context)!;
return InkWell(
onTap: () => _fetchMembersPage(),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(Icons.expand_more, color: cs.primary, size: 26),
const SizedBox(width: 14),
Text(
l10n.chatInfoShowMore,
style: TextStyle(color: cs.primary, fontSize: 16),
),
],
),
),
);
}
Widget _memberAction(
ColorScheme cs,
IconData icon,
@@ -1065,16 +1285,21 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
Widget _memberTile(ColorScheme cs, _MemberInfo member) {
final l10n = AppLocalizations.of(context)!;
final name =
member.name ??
ContactCache.get(member.id) ??
(member.isMe ? l10n.callParticipantYou : '${member.id}');
final avatar = ContactCache.getAvatar(member.id);
final avatar = member.avatarUrl ?? ContactCache.getAvatar(member.id);
final String sublabel;
if (member.isMe) {
if (member.blocked) {
sublabel = l10n.chatInfoMemberDeleted;
} else if (member.isMe) {
sublabel = l10n.callParticipantYou;
} else if (member.isOnline) {
} else if (member.presenceStatus == 1) {
sublabel = l10n.contactProfileOnline;
} else if (member.seenTime != null) {
} else if (member.presenceStatus == 2 || member.presenceStatus == 3) {
sublabel = l10n.contactProfileRecentlyActive;
} else if (member.seenTime != null && member.seenTime! > 0) {
sublabel = formatLastSeen(member.seenTime!);
} else {
sublabel = l10n.contactProfileRecentlyActive;
@@ -1084,57 +1309,95 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
? l10n.chatInfoRoleOwner
: (member.isAdmin ? l10n.chatInfoRoleAdmin : null);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
(avatar != null && avatar.isNotEmpty)
? CircleAvatar(
radius: 22,
backgroundImage: CachedNetworkImageProvider(
avatar,
maxWidth: 144,
maxHeight: 144,
),
backgroundColor: cs.primaryContainer,
)
: CircleAvatar(
radius: 22,
backgroundColor: cs.primaryContainer,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
return InkWell(
onTap: member.isMe
? null
: () => openContactDialogProfile(
context,
contactId: member.id,
name: name,
avatarUrl: avatar,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
if (member.blocked)
_ghostAvatar()
else if (avatar != null && avatar.isNotEmpty)
CircleAvatar(
radius: 22,
backgroundImage: CachedNetworkImageProvider(
avatar,
maxWidth: 144,
maxHeight: 144,
),
backgroundColor: cs.primaryContainer,
)
else
CircleAvatar(
radius: 22,
backgroundColor: cs.primaryContainer,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(color: cs.onPrimaryContainer, fontSize: 16),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 16,
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
Text(
sublabel,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
),
Text(
sublabel,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
],
),
),
),
if (roleLabel != null)
Text(
roleLabel,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
if (member.alias != null)
_memberTag(cs, member.alias!)
else if (roleLabel != null)
Text(
roleLabel,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
),
),
);
}
Widget _ghostAvatar({double radius = 22, double fontSize = 24}) {
return CircleAvatar(
radius: radius,
backgroundColor: const Color(0xFFD4D4D4),
child: Text('👻', style: TextStyle(fontSize: fontSize)),
);
}
Widget _memberTag(ColorScheme cs, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: cs.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: TextStyle(
color: cs.onPrimaryContainer,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
@@ -1374,6 +1637,7 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
}
Widget _avatar() {
if (_peerDeleted) return _ghostAvatar(radius: 48, fontSize: 52);
final avatar = KometAvatar(
name: widget.name,
imageUrl: widget.imageUrl,
@@ -0,0 +1,485 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/main.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/backend/modules/contacts.dart';
import 'package:komet/backend/modules/messages.dart' show ContactCache;
import 'package:komet/core/storage/app_database.dart';
import 'package:komet/core/storage/token_storage.dart';
import 'package:komet/frontend/screens/contacts/contact_sheet_common.dart';
import 'package:komet/frontend/widgets/custom_notification.dart';
import 'package:komet/frontend/widgets/komet_avatar.dart';
import 'package:komet/l10n/app_localizations.dart';
class _Candidate {
final int id;
final String name;
final String? avatarUrl;
const _Candidate({required this.id, required this.name, this.avatarUrl});
}
Future<bool?> showAddMembersSheet(
BuildContext context, {
required int chatId,
required Set<int> excludeIds,
}) {
return showBlurredCard<bool>(
context,
(host) => _AddMembersCard(
chatId: chatId,
excludeIds: excludeIds,
hostContext: host,
),
);
}
class _AddMembersCard extends StatefulWidget {
final int chatId;
final Set<int> excludeIds;
final BuildContext hostContext;
const _AddMembersCard({
required this.chatId,
required this.excludeIds,
required this.hostContext,
});
@override
State<_AddMembersCard> createState() => _AddMembersCardState();
}
class _AddMembersCardState extends State<_AddMembersCard> {
final TextEditingController _searchCtrl = TextEditingController();
final Set<int> _selected = {};
List<_Candidate> _all = [];
String _query = '';
bool _loading = true;
bool _submitting = false;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) {
if (mounted) setState(() => _loading = false);
return;
}
final byId = <int, _Candidate>{};
final contacts = await ContactsModule.getContacts(accountId);
for (final c in contacts) {
if (c.id == accountId || widget.excludeIds.contains(c.id)) continue;
final name = [
c.firstName,
c.lastName,
].where((s) => s != null && s.trim().isNotEmpty).join(' ').trim();
byId[c.id] = _Candidate(
id: c.id,
name: name.isEmpty ? '${c.id}' : name,
avatarUrl: c.baseUrl,
);
}
final dialogs = await AppDatabase.loadDialogChats(accountId);
for (final row in dialogs) {
final chat = CachedChat.fromDbRow(row);
for (final pid in chat.participants.keys) {
if (pid == accountId ||
widget.excludeIds.contains(pid) ||
byId.containsKey(pid)) {
continue;
}
final name = chat.title ?? ContactCache.get(pid) ?? '$pid';
byId[pid] = _Candidate(
id: pid,
name: name,
avatarUrl: chat.iconUrl ?? ContactCache.getAvatar(pid),
);
}
}
final list = byId.values.toList()
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
if (mounted) {
setState(() {
_all = list;
_loading = false;
});
}
}
List<_Candidate> get _filtered {
final q = _query.trim().toLowerCase();
if (q.isEmpty) return _all;
return _all.where((c) => c.name.toLowerCase().contains(q)).toList();
}
Future<void> _submit() async {
if (_selected.isEmpty || _submitting) return;
setState(() => _submitting = true);
final ok = await chats.addMembers(
api,
chatId: widget.chatId,
userIds: _selected.toList(),
);
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
if (ok) {
Navigator.of(context).pop(true);
if (widget.hostContext.mounted) {
showCustomNotification(widget.hostContext, l10n.chatInfoMembersAdded);
}
} else {
setState(() => _submitting = false);
showCustomNotification(context, l10n.chatInfoAddMembersError);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final width = MediaQuery.sizeOf(context).width;
final maxListHeight = MediaQuery.sizeOf(context).height * 0.5;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Material(
color: Colors.transparent,
child: Container(
width: width > 420 ? 380 : double.infinity,
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(22),
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 12),
child: Row(
children: [
Expanded(
child: Text(
l10n.chatInfoAddMember,
style: TextStyle(
color: cs.onSurface,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
),
Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: _searchCtrl,
onChanged: (v) => setState(() => _query = v),
style: TextStyle(color: cs.onSurface, fontSize: 14),
decoration: InputDecoration(
isDense: true,
constraints: const BoxConstraints(maxWidth: 150),
prefixIcon: Icon(
Symbols.search,
size: 18,
color: cs.onSurfaceVariant,
),
prefixIconConstraints: const BoxConstraints(
minWidth: 34,
),
border: InputBorder.none,
hintText: l10n.chatInfoMembersSearchHint,
hintStyle: TextStyle(
color: cs.outline,
fontSize: 14,
),
contentPadding: const EdgeInsets.symmetric(
vertical: 10,
),
),
),
),
],
),
),
Divider(height: 1, thickness: 0.5, color: cs.outlineVariant),
ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxListHeight),
child: _buildList(cs, l10n),
),
Divider(height: 1, thickness: 0.5, color: cs.outlineVariant),
_buildAddButton(cs, l10n),
],
),
),
),
),
);
}
Widget _buildList(ColorScheme cs, AppLocalizations l10n) {
if (_loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
final items = _filtered;
if (items.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Text(
l10n.chatInfoAddMembersEmpty,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
),
),
);
}
return ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: items.length,
itemBuilder: (_, i) => _candidateRow(cs, items[i]),
);
}
Widget _candidateRow(ColorScheme cs, _Candidate c) {
final selected = _selected.contains(c.id);
return InkWell(
onTap: () => setState(() {
if (selected) {
_selected.remove(c.id);
} else {
_selected.add(c.id);
}
}),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
KometAvatar(name: c.name, imageUrl: c.avatarUrl, size: 42),
const SizedBox(width: 14),
Expanded(
child: Text(
c.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
Icon(
selected
? Symbols.check_circle
: Symbols.radio_button_unchecked,
fill: selected ? 1 : 0,
color: selected ? cs.primary : cs.outline,
size: 24,
),
],
),
),
);
}
Widget _buildAddButton(ColorScheme cs, AppLocalizations l10n) {
final enabled = _selected.isNotEmpty && !_submitting;
return InkWell(
onTap: enabled ? _submit : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_submitting)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Text(
_selected.isEmpty
? l10n.chatInfoAddMembersAction
: '${l10n.chatInfoAddMembersAction} · ${_selected.length}',
style: TextStyle(
color: enabled ? cs.primary : cs.onSurfaceVariant,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
Future<void> showInviteLinkSheet(
BuildContext context, {
required String link,
required String title,
String? avatarUrl,
}) {
return showBlurredCard<void>(
context,
(host) => _InviteLinkCard(
link: link,
title: title,
avatarUrl: avatarUrl,
hostContext: host,
),
);
}
class _InviteLinkCard extends StatelessWidget {
final String link;
final String title;
final String? avatarUrl;
final BuildContext hostContext;
const _InviteLinkCard({
required this.link,
required this.title,
this.avatarUrl,
required this.hostContext,
});
String get _shortLink => link.replaceFirst(RegExp(r'^https?://'), '');
Future<void> _copy(BuildContext context) async {
final message = AppLocalizations.of(context)!.sharedLinkCopied;
await Clipboard.setData(ClipboardData(text: link));
if (!context.mounted) return;
Navigator.of(context).pop();
if (hostContext.mounted) showCustomNotification(hostContext, message);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final width = MediaQuery.sizeOf(context).width;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Material(
color: Colors.transparent,
child: Container(
width: width > 420 ? 380 : double.infinity,
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(22),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.chatInfoInviteLink.toUpperCase(),
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
),
),
const SizedBox(height: 10),
Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.fromLTRB(12, 10, 6, 10),
child: Row(
children: [
KometAvatar(name: title, imageUrl: avatarUrl, size: 40),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_shortLink,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
IconButton(
icon: Icon(Symbols.content_copy, color: cs.primary),
onPressed: () => _copy(context),
),
],
),
),
const SizedBox(height: 8),
Text(
l10n.chatInfoInviteLinkHint,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _copy(context),
icon: const Icon(Symbols.content_copy, size: 20),
label: Text(l10n.sharedCopyLink),
),
),
],
),
),
),
),
),
);
}
}
+8
View File
@@ -583,6 +583,14 @@
"chatInfoAddMember": "Add member",
"chatInfoRoleOwner": "owner",
"chatInfoRoleAdmin": "Admin",
"chatInfoMemberDeleted": "Account deleted",
"chatInfoInviteByLink": "Invite via link",
"chatInfoInviteLinkHint": "You can invite anyone with this link",
"chatInfoAddMembersAction": "Add",
"chatInfoMembersSearchHint": "Search",
"chatInfoAddMembersEmpty": "No one to add",
"chatInfoMembersAdded": "Members added",
"chatInfoAddMembersError": "Couldn't add members",
"chatInfoNoData": "No data",
"chatInfoHideExtra": "Hide",
"chatInfoShowMoreExtra": "Details",
+48
View File
@@ -2696,6 +2696,54 @@ abstract class AppLocalizations {
/// **'Admin'**
String get chatInfoRoleAdmin;
/// No description provided for @chatInfoMemberDeleted.
///
/// In en, this message translates to:
/// **'Account deleted'**
String get chatInfoMemberDeleted;
/// No description provided for @chatInfoInviteByLink.
///
/// In en, this message translates to:
/// **'Invite via link'**
String get chatInfoInviteByLink;
/// No description provided for @chatInfoInviteLinkHint.
///
/// In en, this message translates to:
/// **'You can invite anyone with this link'**
String get chatInfoInviteLinkHint;
/// No description provided for @chatInfoAddMembersAction.
///
/// In en, this message translates to:
/// **'Add'**
String get chatInfoAddMembersAction;
/// No description provided for @chatInfoMembersSearchHint.
///
/// In en, this message translates to:
/// **'Search'**
String get chatInfoMembersSearchHint;
/// No description provided for @chatInfoAddMembersEmpty.
///
/// In en, this message translates to:
/// **'No one to add'**
String get chatInfoAddMembersEmpty;
/// No description provided for @chatInfoMembersAdded.
///
/// In en, this message translates to:
/// **'Members added'**
String get chatInfoMembersAdded;
/// No description provided for @chatInfoAddMembersError.
///
/// In en, this message translates to:
/// **'Couldn\'t add members'**
String get chatInfoAddMembersError;
/// No description provided for @chatInfoNoData.
///
/// In en, this message translates to:
+24
View File
@@ -1385,6 +1385,30 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get chatInfoRoleAdmin => 'Admin';
@override
String get chatInfoMemberDeleted => 'Account deleted';
@override
String get chatInfoInviteByLink => 'Invite via link';
@override
String get chatInfoInviteLinkHint => 'You can invite anyone with this link';
@override
String get chatInfoAddMembersAction => 'Add';
@override
String get chatInfoMembersSearchHint => 'Search';
@override
String get chatInfoAddMembersEmpty => 'No one to add';
@override
String get chatInfoMembersAdded => 'Members added';
@override
String get chatInfoAddMembersError => 'Couldn\'t add members';
@override
String get chatInfoNoData => 'No data';
+25
View File
@@ -1393,6 +1393,31 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get chatInfoRoleAdmin => 'Админ';
@override
String get chatInfoMemberDeleted => 'Аккаунт удалён';
@override
String get chatInfoInviteByLink => 'Пригласить по ссылке';
@override
String get chatInfoInviteLinkHint =>
'Вы можете пригласить любого человека по этой ссылке';
@override
String get chatInfoAddMembersAction => 'Добавить';
@override
String get chatInfoMembersSearchHint => 'Поиск';
@override
String get chatInfoAddMembersEmpty => 'Некого добавить';
@override
String get chatInfoMembersAdded => 'Участники добавлены';
@override
String get chatInfoAddMembersError => 'Не удалось добавить участников';
@override
String get chatInfoNoData => 'Нет данных';
+8
View File
@@ -456,6 +456,14 @@
"chatInfoAddMember": "Добавить участника",
"chatInfoRoleOwner": "Владелец",
"chatInfoRoleAdmin": "Админ",
"chatInfoMemberDeleted": "Аккаунт удалён",
"chatInfoInviteByLink": "Пригласить по ссылке",
"chatInfoInviteLinkHint": "Вы можете пригласить любого человека по этой ссылке",
"chatInfoAddMembersAction": "Добавить",
"chatInfoMembersSearchHint": "Поиск",
"chatInfoAddMembersEmpty": "Некого добавить",
"chatInfoMembersAdded": "Участники добавлены",
"chatInfoAddMembersError": "Не удалось добавить участников",
"chatInfoNoData": "Нет данных",
"chatInfoHideExtra": "Скрыть",
"chatInfoShowMoreExtra": "Подробнее",
+10
View File
@@ -23,6 +23,16 @@ class ChatInfo {
bool isAdmin(int id) => adminIds.contains(id);
bool isOwner(int id) => owner != null && id == owner;
String? adminAlias(int id) {
final source = raw['adminParticipants'];
if (source is! Map) return null;
final entry = source[id.toString()] ?? source[id];
if (entry is! Map) return null;
final alias = entry['alias'];
if (alias is String && alias.trim().isNotEmpty) return alias.trim();
return null;
}
int? get participantsCount => raw['participantsCount'] as int?;
int? get blockedParticipantsCount => raw['blockedParticipantsCount'] as int?;
String? get link => raw['link'] as String?;
+5
View File
@@ -69,5 +69,10 @@ class ContactInfo {
bool get isBot => options.contains('BOT');
bool get isDeleted {
final status = raw['accountStatus'];
return status is int && status != 0;
}
int? get id => raw['id'] as int?;
}