From 20503c9b1adc335084271c509639568be3c96b68 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 25 Jul 2026 15:30:07 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=20=D1=81=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 141 +++++ lib/core/cache/info_cache.dart | 40 ++ lib/core/storage/app_database.dart | 11 + .../screens/chats/chat_info_screen.dart | 440 ++++++++++++---- .../screens/chats/group_invite_sheets.dart | 485 ++++++++++++++++++ lib/l10n/app_en.arb | 8 + lib/l10n/app_localizations.dart | 48 ++ lib/l10n/app_localizations_en.dart | 24 + lib/l10n/app_localizations_ru.dart | 25 + lib/l10n/app_ru.arb | 8 + lib/models/chat_info.dart | 10 + lib/models/contact_info.dart | 5 + 12 files changed, 1157 insertions(+), 88 deletions(-) create mode 100644 lib/frontend/screens/chats/group_invite_sheets.dart diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 918c224..c8686c9 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -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 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 addMembers( + Api api, { + required int chatId, + required List 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(), 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 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 = []; + final presenceById = >{}; + final rawMembers = payload['members']; + if (rawMembers is List) { + for (final m in rawMembers.whereType()) { + final contact = m['contact']; + if (contact is! Map) continue; + final id = contact['id']; + if (id is! int) continue; + + final info = ContactInfo.fromMap(Map.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()); + + final presence = m['presence']; + var status = 0; + int? seen; + if (presence is Map) { + final p = Map.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> refreshChats(Api api, List chatIds) async { if (chatIds.isEmpty) return const []; try { diff --git a/lib/core/cache/info_cache.dart b/lib/core/cache/info_cache.dart index c69f1aa..2725490 100644 --- a/lib/core/cache/info_cache.dart +++ b/lib/core/cache/info_cache.dart @@ -113,6 +113,46 @@ class ContactInfoFetch { _cache.putValue(id, ContactInfo.fromMap(Map.from(contact))); } + static Future> getMany( + List ids, { + bool forceRefresh = false, + }) async { + final result = {}; + final missing = []; + 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()) { + final id = c['id']; + if (id is! int) continue; + final info = ContactInfo.fromMap(Map.from(c)); + _cache.putValue(id, info, at: now); + result[id] = info; + } + } + } catch (_) {} + return result; + } + static Future _fetch(int id) async { final api = _api; if (api == null || api.state != SessionState.online) return null; diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index d95b9d0..d14bbfa 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -883,6 +883,17 @@ class AppDatabase { ); } + static Future> 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?> loadContact( int accountId, int id, diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 27a9113..6c73c87 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -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 { 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 _seenMemberIds = {}; + Set _contactIds = {}; + int _memberMarker = 0; + bool _membersLoading = false; + bool _membersEnd = false; int _mediaChatId = 0; String? _anchorMsgId; @@ -97,6 +120,7 @@ class _ChatInfoScreenState extends State { @override void initState() { super.initState(); + _bodyScrollController.addListener(_onBodyScroll); _load(); } @@ -207,34 +231,9 @@ class _ChatInfoScreenState extends State { setState(() => _isLoading = false); return; } else if (widget.chatType == 'CHAT') { - final chatInfo = _chatInfo!; - final memberIds = chatInfo.participantIds; - - Map> 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 { 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 _loadLeaders() async { + final info = _chatInfo; + if (info == null) return; + + final owner = info.owner; + final leaderIds = [ + 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 _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 _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 _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 { 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 { ); 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 { 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 { 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 { ), 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 { 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 { ? 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 { } Widget _avatar() { + if (_peerDeleted) return _ghostAvatar(radius: 48, fontSize: 52); final avatar = KometAvatar( name: widget.name, imageUrl: widget.imageUrl, diff --git a/lib/frontend/screens/chats/group_invite_sheets.dart b/lib/frontend/screens/chats/group_invite_sheets.dart new file mode 100644 index 0000000..a1c0be0 --- /dev/null +++ b/lib/frontend/screens/chats/group_invite_sheets.dart @@ -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 showAddMembersSheet( + BuildContext context, { + required int chatId, + required Set excludeIds, +}) { + return showBlurredCard( + context, + (host) => _AddMembersCard( + chatId: chatId, + excludeIds: excludeIds, + hostContext: host, + ), + ); +} + +class _AddMembersCard extends StatefulWidget { + final int chatId; + final Set 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 _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 _load() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) { + if (mounted) setState(() => _loading = false); + return; + } + final byId = {}; + + 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 _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 showInviteLinkSheet( + BuildContext context, { + required String link, + required String title, + String? avatarUrl, +}) { + return showBlurredCard( + 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 _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), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3fa78a1..acc6067 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 118922c..2c09ad3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e7be3b4..0b84ebb 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4bfe052..389912f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Нет данных'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e6f9a26..e555f81 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -456,6 +456,14 @@ "chatInfoAddMember": "Добавить участника", "chatInfoRoleOwner": "Владелец", "chatInfoRoleAdmin": "Админ", + "chatInfoMemberDeleted": "Аккаунт удалён", + "chatInfoInviteByLink": "Пригласить по ссылке", + "chatInfoInviteLinkHint": "Вы можете пригласить любого человека по этой ссылке", + "chatInfoAddMembersAction": "Добавить", + "chatInfoMembersSearchHint": "Поиск", + "chatInfoAddMembersEmpty": "Некого добавить", + "chatInfoMembersAdded": "Участники добавлены", + "chatInfoAddMembersError": "Не удалось добавить участников", "chatInfoNoData": "Нет данных", "chatInfoHideExtra": "Скрыть", "chatInfoShowMoreExtra": "Подробнее", diff --git a/lib/models/chat_info.dart b/lib/models/chat_info.dart index 0c4ff35..d418f20 100644 --- a/lib/models/chat_info.dart +++ b/lib/models/chat_info.dart @@ -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?; diff --git a/lib/models/contact_info.dart b/lib/models/contact_info.dart index a938c78..b411000 100644 --- a/lib/models/contact_info.dart +++ b/lib/models/contact_info.dart @@ -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?; }