From 78fa0251c54a0eda151b0bc4f5654f5efe45c5b9 Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 17 May 2026 21:24:59 +0700 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=90=D0=9D=D0=9E=D0=9D=20=D0=9F=D0=A0?= =?UTF-8?q?=D0=90=D0=91=D0=98=D0=A4=20=D0=94=D0=9E=D0=9A=D0=A1=20=D0=A1?= =?UTF-8?q?=D0=92=D0=AF=D0=A2=20=D0=9F=D0=A0=D0=9E=D0=A4=D0=98=D0=9B=D0=95?= =?UTF-8?q?=D0=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/storage/app_database.dart | 13 + .../screens/chats/chat_info_screen.dart | 131 ++++- lib/frontend/screens/chats/chat_screen.dart | 9 + .../contacts/contact_profile_screen.dart | 456 ++++++++++++++++++ .../screens/contacts/contacts_tab.dart | 209 +++++++- .../screens/profile/debug_menu_screen.dart | 440 +++++++++++++++-- 6 files changed, 1198 insertions(+), 60 deletions(-) create mode 100644 lib/frontend/screens/contacts/contact_profile_screen.dart diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 3b61d69..8a1d41b 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -471,6 +471,19 @@ class AppDatabase { ); } + static Future findDialogChatByParticipant(int accountId, int contactId) async { + final db = await _instance; + final rows = await db.query( + 'chats_cache', + columns: ['id'], + where: "account_id = ? AND type = 'DIALOG' AND participants LIKE ?", + whereArgs: [accountId, '%"$contactId":%'], + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first['id'] as int?; + } + static Future>> loadDialogChats(int accountId) async { final db = await _instance; return db.query( diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 7e93b79..777c32c 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/protocol/opcode_map.dart'; import '../../../core/storage/app_database.dart'; @@ -47,6 +48,7 @@ class _ChatInfoScreenState extends State { int _myId = 0; bool _isLoading = true; + bool _extraContactExpanded = false; Map? _chatData; String _selectedTab = ''; bool _descExpanded = false; @@ -921,34 +923,121 @@ class _ChatInfoScreenState extends State { style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < rows.length; i++) ...[ - _infoRow(cs, rows[i].label, rows[i].value), - if (i < rows.length - 1) - Divider( - height: 10, - color: cs.outlineVariant.withValues(alpha: 0.25)), + final extraRows = _buildExtraContactRows(); + + return AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < rows.length; i++) ...[ + _infoRow( + cs, + rows[i].label, + rows[i].value, + trailing: _trailingFor(rows[i].label, cs), + ), + if (i < rows.length - 1 || (_extraContactExpanded && extraRows.isNotEmpty)) + Divider( + height: 10, + color: cs.outlineVariant.withValues(alpha: 0.25)), + ], + if (_extraContactExpanded) + for (int i = 0; i < extraRows.length; i++) ...[ + _infoRow(cs, extraRows[i].label, extraRows[i].value), + if (i < extraRows.length - 1) + Divider( + height: 10, + color: cs.outlineVariant.withValues(alpha: 0.25)), + ], ], - ], + ), ); } - Widget _infoRow(ColorScheme cs, String label, String value) { + List<({String label, String value})> _buildExtraContactRows() { + final c = _contactData; + if (c == null) return const []; + final rows = <({String label, String value})>[]; + final reg = c['registrationTime']; + if (reg is int && reg > 0) { + rows.add((label: 'Регистрация', value: _formatTs(reg))); + } + final upd = c['updateTime']; + if (upd is int && upd > 0) { + rows.add((label: 'Обновлён', value: _formatTs(upd))); + } + final country = c['country']; + if (country is String && country.isNotEmpty) { + rows.add((label: 'Страна', value: country)); + } + final gender = c['gender']; + if (gender is int) { + final g = gender == 1 ? 'Мужской' : (gender == 2 ? 'Женский' : null); + if (g != null) rows.add((label: 'Пол', value: g)); + } + final phone = c['phone']; + if (phone is int && phone > 0) { + rows.add((label: 'Телефон', value: '+$phone')); + } else if (phone is String && phone.isNotEmpty && phone != '***') { + rows.add((label: 'Телефон', value: phone)); + } + final accStatus = c['accountStatus']; + if (accStatus is int && accStatus != 0) { + rows.add((label: 'Статус аккаунта', value: accStatus.toString())); + } + final opts = c['options']; + if (opts is List && opts.isNotEmpty) { + rows.add((label: 'Флаги', value: opts.whereType().join(', '))); + } + final link = c['link']; + if (link is String && link.isNotEmpty) { + rows.add((label: 'Ссылка', value: link)); + } + return rows; + } + + Widget? _trailingFor(String label, ColorScheme cs) { + if (label != 'ID чата') return null; + if (widget.chatType != 'DIALOG') return null; + if (_contactData == null) return null; + return IconButton( + tooltip: _extraContactExpanded ? 'Скрыть' : 'Подробнее', + icon: AnimatedRotation( + turns: _extraContactExpanded ? 0.125 : 0, + duration: const Duration(milliseconds: 220), + child: Icon(Symbols.add_circle, color: cs.primary, size: 22), + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + onPressed: () => setState(() => _extraContactExpanded = !_extraContactExpanded), + ); + } + + Widget _infoRow(ColorScheme cs, String label, String value, {Widget? trailing}) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text(label, - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10)), - Text(value, - style: TextStyle( - color: cs.onSurface, - fontSize: 12, - fontWeight: FontWeight.w500)), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 10)), + Text(value, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500)), + ], + ), + ), + ?trailing, ], ), ); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index fbaa3a0..003b00a 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -270,6 +270,15 @@ class _ChatScreenState extends State ); }); } + + if (chat == null) { + unawaited( + ChatsModule.refreshChats(api, [widget.chatId]).then((list) { + if (!mounted || list.isEmpty) return; + setState(() => chat = list.first); + }), + ); + } } catch (e) { Haptics.error(); final index = _messages.indexWhere((m) => m.id == tempId); diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart new file mode 100644 index 0000000..034674b --- /dev/null +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -0,0 +1,456 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../core/storage/token_storage.dart'; +import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; +import '../chats/chat_screen.dart'; + +class ContactProfileScreen extends StatefulWidget { + final int contactId; + final String? initialName; + final String? initialAvatarUrl; + + const ContactProfileScreen({ + super.key, + required this.contactId, + this.initialName, + this.initialAvatarUrl, + }); + + @override + State createState() => _ContactProfileScreenState(); +} + +class _ContactProfileScreenState extends State { + bool _loading = true; + Map? _contact; + int? _seenTime; + bool _isOnline = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final results = await Future.wait([ + api.sendRequest(Opcode.contactInfo, {'contactIds': [widget.contactId]}), + api.sendRequest(Opcode.contactPresence, {'contactIds': [widget.contactId]}), + ]); + if (!mounted) return; + final infoPacket = results[0]; + if (infoPacket.isOk) { + final contacts = (infoPacket.payload as Map?)?['contacts'] as List?; + if (contacts != null && contacts.isNotEmpty) { + _contact = Map.from(contacts.first as Map); + } + } + final presencePacket = results[1]; + if (presencePacket.isOk) { + final presence = (presencePacket.payload as Map?)?['presence'] as Map?; + final p = presence?[widget.contactId.toString()] ?? presence?[widget.contactId]; + if (p is Map) { + _seenTime = p['seen'] as int?; + _isOnline = ((p['status'] as int?) ?? 0) > 0; + } + } + } catch (e) { + if (mounted) showCustomNotification(context, 'Ошибка: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + String _displayName() { + final c = _contact; + if (c != null) { + final names = c['names']; + if (names is List && names.isNotEmpty) { + final n = names.first; + if (n is Map) { + final full = n['name']?.toString(); + if (full != null && full.isNotEmpty) return full; + final first = n['firstName']?.toString() ?? ''; + final last = n['lastName']?.toString() ?? ''; + final combined = '$first $last'.trim(); + if (combined.isNotEmpty) return combined; + } + } + } + return widget.initialName ?? 'User #${widget.contactId}'; + } + + String? _avatarUrl() { + return (_contact?['baseUrl'] as String?) ?? widget.initialAvatarUrl; + } + + Set _options() { + final raw = _contact?['options']; + if (raw is List) return raw.whereType().toSet(); + return const {}; + } + + bool get _isBot => _options().contains('BOT'); + bool get _isVerified => _options().contains('OFFICIAL'); + + String _subtitle() { + if (_isBot) return 'Бот'; + if (_isOnline) return 'В сети'; + if (_seenTime != null && _seenTime! > 0) return _formatLastSeen(_seenTime!); + return ''; + } + + String _formatLastSeen(int secondsSinceEpoch) { + final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 2) return 'Был(-а) только что'; + if (diff.inMinutes < 60) return 'Был(-а) ${diff.inMinutes} мин назад'; + if (diff.inHours < 24) return 'Был(-а) ${diff.inHours} ч назад'; + if (diff.inDays < 7) return 'Был(-а) ${diff.inDays} дн назад'; + return 'Был(-а) ${_formatDate(dt)}'; + } + + String _formatDate(DateTime dt) { + const months = [ + 'янв', 'фев', 'мар', 'апр', 'мая', 'июн', + 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек', + ]; + return '${dt.day} ${months[dt.month - 1]} ${dt.year}'; + } + + String _formatDateTime(int msSinceEpoch) { + final dt = DateTime.fromMillisecondsSinceEpoch(msSinceEpoch); + final hh = dt.hour.toString().padLeft(2, '0'); + final mm = dt.minute.toString().padLeft(2, '0'); + return '${_formatDate(dt)}, $hh:$mm'; + } + + String? _formatPhone(dynamic raw) { + String? digits; + if (raw is int && raw > 0) { + digits = raw.toString(); + } else if (raw is String && raw.isNotEmpty && raw != '***') { + digits = raw.replaceAll(RegExp(r'[^0-9]'), ''); + if (digits.isEmpty) return null; + } + if (digits == null) return null; + if (digits.length == 11 && digits.startsWith('7')) { + final p = digits; + return '+${p[0]} (${p.substring(1, 4)}) ${p.substring(4, 7)}-${p.substring(7, 9)}-${p.substring(9)}'; + } + return '+$digits'; + } + + String? _formatGender(dynamic raw) { + if (raw is! int) return null; + switch (raw) { + case 1: + return 'Мужской'; + case 2: + return 'Женский'; + default: + return null; + } + } + + Future _openChat() async { + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + final existing = await AppDatabase.findDialogChatByParticipant( + accountId, + widget.contactId, + ); + final chatId = existing ?? (accountId ^ widget.contactId); + if (!mounted) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ChatScreen( + chatId: chatId, + name: _displayName(), + imageUrl: _avatarUrl() ?? '', + chatType: 'DIALOG', + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _buildBody(cs), + ), + ); + } + + Widget _buildBody(ColorScheme cs) { + return CustomScrollView( + slivers: [ + SliverAppBar( + backgroundColor: Colors.transparent, + elevation: 0, + floating: true, + leading: IconButton( + icon: Icon(Symbols.arrow_back, color: cs.onSurface), + onPressed: () => Navigator.pop(context), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + _buildAvatar(cs), + const SizedBox(height: 14), + _buildNameRow(cs), + const SizedBox(height: 4), + Text( + _subtitle(), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 20), + _buildActions(cs), + const SizedBox(height: 16), + _buildInfoCard(cs), + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ); + } + + Widget _buildAvatar(ColorScheme cs) { + final url = _avatarUrl(); + return Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primaryContainer, + ), + child: (url != null && url.isNotEmpty) + ? ClipOval( + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + errorWidget: (_, _, _) => _avatarLetters(cs), + ), + ) + : _avatarLetters(cs), + ); + } + + Widget _avatarLetters(ColorScheme cs) { + final name = _displayName(); + return Center( + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildNameRow(ColorScheme cs) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + _displayName(), + style: TextStyle( + color: cs.onSurface, + fontSize: 22, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (_isVerified) ...[ + const SizedBox(width: 6), + Icon( + Symbols.verified, + color: cs.primary, + size: 20, + fill: 1, + ), + ], + ], + ); + } + + Widget _buildActions(ColorScheme cs) { + final actions = <({IconData icon, String label, VoidCallback? onTap})>[ + (icon: Symbols.chat_bubble, label: 'Чат', onTap: _openChat), + (icon: Symbols.notifications, label: 'Звук', onTap: null), + if (!_isBot) + (icon: Symbols.call, label: 'Звонок', onTap: null), + ]; + return Row( + children: [ + for (var i = 0; i < actions.length; i++) ...[ + Expanded( + child: GestureDetector( + onTap: actions[i].onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(actions[i].icon, color: cs.primary, size: 22), + const SizedBox(height: 4), + Text( + actions[i].label, + style: TextStyle(color: cs.onSurface, fontSize: 12), + ), + ], + ), + ), + ), + ), + if (i < actions.length - 1) const SizedBox(width: 8), + ], + ], + ); + } + + Widget _buildInfoCard(ColorScheme cs) { + final c = _contact; + if (c == null) return const SizedBox.shrink(); + + final rows = []; + + final phoneStr = _formatPhone(c['phone']); + if (phoneStr != null) { + rows.add(_infoRow(cs, Symbols.phone, 'Телефон', phoneStr)); + } + + final country = c['country'] as String?; + if (country != null && country.isNotEmpty) { + rows.add(_infoRow(cs, Symbols.public, 'Страна', country)); + } + + final genderStr = _formatGender(c['gender']); + if (genderStr != null) { + rows.add(_infoRow(cs, Symbols.wc, 'Пол', genderStr)); + } + + final regTime = c['registrationTime'] as int?; + if (regTime != null && regTime > 0) { + rows.add(_infoRow(cs, Symbols.event, 'Регистрация', _formatDateTime(regTime))); + } + + final updateTime = c['updateTime'] as int?; + if (updateTime != null && updateTime > 0) { + rows.add(_infoRow(cs, Symbols.update, 'Обновлён', _formatDateTime(updateTime))); + } + + final accountStatus = c['accountStatus']; + if (accountStatus is int && accountStatus != 0) { + rows.add(_infoRow(cs, Symbols.account_circle, 'Статус аккаунта', accountStatus.toString())); + } + + final desc = (c['description'] as String?)?.trim(); + if (desc != null && desc.isNotEmpty) { + rows.add(_infoRow(cs, Symbols.info, 'Описание', desc, multiline: true)); + } + + final link = c['link'] as String?; + if (link != null && link.isNotEmpty) { + rows.add(_infoRow(cs, Symbols.link, 'Ссылка', link)); + } + + final webApp = c['webApp'] as String?; + if (webApp != null && webApp.isNotEmpty) { + rows.add(_infoRow(cs, Symbols.web, 'Web app', webApp)); + } + + final opts = _options(); + if (opts.isNotEmpty) { + rows.add(_infoRow(cs, Symbols.label, 'Флаги', opts.join(', '), multiline: true)); + } + + rows.add(_infoRow(cs, Symbols.tag, 'ID', widget.contactId.toString())); + + if (rows.isEmpty) return const SizedBox.shrink(); + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + for (var i = 0; i < rows.length; i++) ...[ + if (i > 0) + Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.3)), + rows[i], + ], + ], + ), + ); + } + + Widget _infoRow( + ColorScheme cs, + IconData icon, + String label, + String value, { + bool multiline = false, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 20), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle(color: cs.onSurface, fontSize: 14), + maxLines: multiline ? null : 1, + overflow: multiline ? null : TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 7c15683..3e4abdc 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -1,8 +1,12 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; import '../../../backend/modules/contacts.dart'; +import '../../../main.dart'; +import 'contact_profile_screen.dart'; class ContactsTab extends StatefulWidget { const ContactsTab({super.key}); @@ -21,6 +25,19 @@ class _ContactsTabState extends State { _loadContacts(); } + Future _openSearchById() async { + final cs = Theme.of(context).colorScheme; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => const _SearchContactSheet(), + ); + } + Future _loadContacts() async { final p = await AppDatabase.loadActiveProfile(); if (p == null) { @@ -67,7 +84,16 @@ class _ContactsTabState extends State { color: Colors.transparent, child: InkWell( onTap: () { - // Open contact details or chat + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ContactProfileScreen( + contactId: contact.id, + initialName: nameToDisplay, + initialAvatarUrl: contact.baseUrl, + ), + ), + ); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), @@ -182,7 +208,7 @@ class _ContactsTabState extends State { ), IconButton( icon: Icon(Symbols.search, color: cs.onSurface), - onPressed: () {}, + onPressed: _openSearchById, ), ], ), @@ -216,3 +242,182 @@ class _ContactsTabState extends State { ); } } + +class _SearchContactSheet extends StatefulWidget { + const _SearchContactSheet(); + + @override + State<_SearchContactSheet> createState() => _SearchContactSheetState(); +} + +class _SearchContactSheetState extends State<_SearchContactSheet> { + final _controller = TextEditingController(); + bool _loading = false; + String? _error; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _submit() async { + final raw = _controller.text.trim(); + final id = int.tryParse(raw); + if (id == null) { + setState(() => _error = 'Введите числовой ID'); + return; + } + setState(() { + _loading = true; + _error = null; + }); + try { + final packet = await api.sendRequest(Opcode.contactInfo, { + 'contactIds': [id], + }); + final contacts = (packet.payload as Map?)?['contacts'] as List?; + if (contacts == null || contacts.isEmpty) { + if (mounted) { + setState(() { + _loading = false; + _error = 'Контакт с таким ID не найден'; + }); + } + return; + } + final raw = Map.from(contacts.first as Map); + String? name; + final namesRaw = raw['names']; + if (namesRaw is List && namesRaw.isNotEmpty) { + final n = namesRaw.first; + if (n is Map) name = n['name']?.toString(); + } + if (!mounted) return; + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ContactProfileScreen( + contactId: id, + initialName: name, + initialAvatarUrl: raw['baseUrl'] as String?, + ), + ), + ); + } on PacketError catch (e) { + if (mounted) { + setState(() { + _loading = false; + _error = e.message; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _loading = false; + _error = 'Ошибка: $e'; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final viewInsets = MediaQuery.of(context).viewInsets; + return Padding( + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Поиск по ID', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Symbols.close, color: cs.onSurfaceVariant), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _controller, + autofocus: true, + keyboardType: TextInputType.number, + enabled: !_loading, + onSubmitted: (_) => _submit(), + onChanged: (_) { + if (_error != null) setState(() => _error = null); + }, + style: TextStyle(color: cs.onSurface, fontSize: 16), + decoration: InputDecoration( + hintText: 'Введите ID контакта', + hintStyle: TextStyle(color: cs.onSurfaceVariant, fontSize: 16), + prefixIcon: Icon(Symbols.tag, color: cs.onSurfaceVariant, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 14, + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: cs.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Symbols.error_outline, size: 18, color: cs.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: TextStyle(color: cs.onErrorContainer, fontSize: 13), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 16), + FilledButton( + onPressed: _loading ? null : _submit, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Найти'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index ffac3c3..ed594bc 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -1,8 +1,13 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/protocol/packet.dart'; import '../../../core/utils/logger.dart'; import '../../../main.dart'; +import '../../widgets/custom_notification.dart'; class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); @@ -13,8 +18,10 @@ class DebugMenuScreen extends StatefulWidget { class _DebugMenuScreenState extends State { final _idController = TextEditingController(); - String? _searchResult; bool _isSearching = false; + bool _hasSearched = false; + final List<_SearchHit> _hits = []; + final Map _errors = {}; @override void dispose() { @@ -27,26 +34,57 @@ class _DebugMenuScreenState extends State { if (id == null) return; setState(() { _isSearching = true; - _searchResult = null; + _hasSearched = true; + _hits.clear(); + _errors.clear(); }); - try { - final result = await ChatsModule.searchById(api, id); - logger.i('searchById result: $result'); - if (!mounted) return; - if (result is Map && result.containsKey('error')) { - final errorMsg = result['localizedMessage'] ?? result['message'] ?? result['error'] ?? 'Error'; - setState(() => _searchResult = 'Error: $errorMsg'); - } else if (result is Map) { - setState(() => _searchResult = result.toString()); - } else { - setState(() => _searchResult = result?.toString() ?? 'null'); + + Future tryProbe(String label, Future Function() probe) async { + try { + final res = await probe(); + logger.i('debug-search $label($id): $res'); + if (res is Map) _extractHits(label, res); + } on PacketError catch (e) { + _errors[label] = e.message; + } catch (e) { + _errors[label] = e.toString(); } - } catch (e) { - if (mounted) { - setState(() => _searchResult = 'Exception: $e'); + } + + await Future.wait([ + tryProbe('contactInfo', () async { + final p = await api.sendRequest(Opcode.contactInfo, {'contactIds': [id]}); + return p.payload; + }), + tryProbe('chatInfo', () async { + final p = await api.sendRequest(Opcode.chatInfo, {'chatIds': [id]}); + return p.payload; + }), + tryProbe('publicSearch', () => ChatsModule.searchById(api, id)), + ]); + + if (!mounted) return; + setState(() => _isSearching = false); + } + + void _extractHits(String source, Map raw) { + final contacts = raw['contacts']; + if (contacts is List) { + for (final c in contacts) { + if (c is Map) { + final hit = _SearchHit.fromContact(source, c); + if (hit != null) _hits.add(hit); + } + } + } + final chats = raw['chats']; + if (chats is List) { + for (final c in chats) { + if (c is Map) { + final hit = _SearchHit.fromChat(source, c); + if (hit != null) _hits.add(hit); + } } - } finally { - if (mounted) setState(() => _isSearching = false); } } @@ -307,13 +345,21 @@ class _DebugMenuScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Поиск по ID (opcode 60)', + 'Поиск по ID', style: TextStyle( color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w500, ), ), + const SizedBox(height: 4), + Text( + 'Параллельно: contactInfo (32) + chatInfo (48) + publicSearch (60)', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), const SizedBox(height: 12), Row( children: [ @@ -322,7 +368,7 @@ class _DebugMenuScreenState extends State { controller: _idController, keyboardType: TextInputType.number, decoration: InputDecoration( - hintText: 'Введите user ID', + hintText: 'Введите ID', border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), @@ -349,27 +395,24 @@ class _DebugMenuScreenState extends State { ), ], ), - if (_searchResult != null) ...[ + if (_hasSearched && !_isSearching) ...[ const SizedBox(height: 12), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: cs.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - constraints: const BoxConstraints(maxHeight: 400), - child: SingleChildScrollView( + if (_hits.isEmpty && _errors.isEmpty) + Padding( + padding: const EdgeInsets.all(12), child: Text( - _searchResult!, - style: TextStyle( - color: cs.onSurface, - fontSize: 12, - fontFamily: 'monospace', - ), + 'Ничего не найдено', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ), - ), + for (final hit in _hits) ...[ + _SearchResultCard(hit: hit), + const SizedBox(height: 8), + ], + for (final entry in _errors.entries) ...[ + _ErrorChip(label: entry.key, message: entry.value), + const SizedBox(height: 6), + ], ], ], ), @@ -382,4 +425,327 @@ class _DebugMenuScreenState extends State { ), ); } +} + +enum _HitKind { dialog, chat, channel, bot, official, contact, user, unknown } + +class _SearchHit { + final String source; + final int id; + final String title; + final String? subtitle; + final String? avatarUrl; + final List<_HitKind> badges; + final bool isChatEntity; + + _SearchHit({ + required this.source, + required this.id, + required this.title, + required this.avatarUrl, + required this.badges, + required this.isChatEntity, + this.subtitle, + }); + + static _SearchHit? fromContact(String source, Map raw) { + final id = raw['id']; + if (id is! int) return null; + final namesRaw = raw['names']; + String title = 'User #$id'; + if (namesRaw is List && namesRaw.isNotEmpty) { + final n = namesRaw.first; + if (n is Map) { + final full = n['name']?.toString(); + if (full != null && full.isNotEmpty) title = full; + } + } + final opts = (raw['options'] is List) + ? (raw['options'] as List).whereType().toSet() + : {}; + final badges = <_HitKind>[]; + if (opts.contains('BOT')) badges.add(_HitKind.bot); + if (opts.contains('OFFICIAL')) badges.add(_HitKind.official); + if (badges.isEmpty) badges.add(_HitKind.contact); + return _SearchHit( + source: source, + id: id, + title: title, + subtitle: (raw['description'] as String?)?.trim().isNotEmpty == true + ? raw['description'] as String + : (raw['phone'] != null ? 'Телефон скрыт' : null), + avatarUrl: raw['baseUrl'] as String?, + badges: badges, + isChatEntity: false, + ); + } + + static _SearchHit? fromChat(String source, Map raw) { + final id = raw['id']; + if (id is! int) return null; + final type = (raw['type'] as String?) ?? 'CHAT'; + final title = (raw['title'] as String?) ?? 'Chat #$id'; + final pCount = raw['participantsCount'] as int?; + final badges = <_HitKind>[]; + switch (type) { + case 'DIALOG': + badges.add(_HitKind.dialog); + case 'CHANNEL': + badges.add(_HitKind.channel); + case 'CHAT': + badges.add(_HitKind.chat); + default: + badges.add(_HitKind.unknown); + } + final opts = raw['options']; + if (opts is Map && opts['OFFICIAL'] == true) { + badges.add(_HitKind.official); + } + String? subtitle; + if (type == 'CHANNEL') { + subtitle = pCount != null ? 'Канал · $pCount подписч.' : 'Канал'; + } else if (type == 'CHAT') { + subtitle = pCount != null ? 'Группа · $pCount участн.' : 'Группа'; + } else { + subtitle = 'Диалог'; + } + return _SearchHit( + source: source, + id: id, + title: title, + subtitle: subtitle, + avatarUrl: raw['baseIconUrl'] as String?, + badges: badges, + isChatEntity: true, + ); + } +} + +class _SearchResultCard extends StatelessWidget { + final _SearchHit hit; + const _SearchResultCard({required this.hit}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _HitAvatar(hit: hit, cs: cs), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + hit.title, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + for (final b in hit.badges) ...[ + const SizedBox(width: 6), + _BadgeChip(kind: b, cs: cs), + ], + ], + ), + if (hit.subtitle != null) ...[ + const SizedBox(height: 2), + Text( + hit.subtitle!, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 2), + Row( + children: [ + Text( + 'id: ${hit.id}', + style: TextStyle( + color: cs.outline, + fontSize: 11, + fontFamily: 'monospace', + ), + ), + const SizedBox(width: 8), + Text( + 'via ${hit.source}', + style: TextStyle(color: cs.outline, fontSize: 11), + ), + ], + ), + ], + ), + ), + IconButton( + tooltip: 'Скопировать id', + icon: Icon(Symbols.content_copy, size: 18, color: cs.onSurfaceVariant), + onPressed: () async { + await Clipboard.setData(ClipboardData(text: hit.id.toString())); + if (context.mounted) { + showCustomNotification(context, 'id скопирован'); + } + }, + ), + ], + ), + ); + } +} + +class _HitAvatar extends StatelessWidget { + final _SearchHit hit; + final ColorScheme cs; + const _HitAvatar({required this.hit, required this.cs}); + + @override + Widget build(BuildContext context) { + const size = 44.0; + final url = hit.avatarUrl; + if (url != null && url.isNotEmpty) { + return ClipOval( + child: CachedNetworkImage( + imageUrl: url, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (_, _) => _fallback(), + errorWidget: (_, _, _) => _fallback(), + ), + ); + } + return _fallback(); + } + + Widget _fallback() { + final initial = hit.title.isNotEmpty ? hit.title[0].toUpperCase() : '?'; + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: cs.primaryContainer, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + initial, + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _BadgeChip extends StatelessWidget { + final _HitKind kind; + final ColorScheme cs; + const _BadgeChip({required this.kind, required this.cs}); + + @override + Widget build(BuildContext context) { + String label; + Color bg; + Color fg; + switch (kind) { + case _HitKind.bot: + label = 'Bot'; + bg = cs.tertiaryContainer; + fg = cs.onTertiaryContainer; + case _HitKind.official: + label = '✓'; + bg = cs.primary; + fg = cs.onPrimary; + case _HitKind.contact: + label = 'Контакт'; + bg = cs.surface; + fg = cs.onSurfaceVariant; + case _HitKind.user: + label = 'User'; + bg = cs.surface; + fg = cs.onSurfaceVariant; + case _HitKind.dialog: + label = 'Диалог'; + bg = cs.secondaryContainer; + fg = cs.onSecondaryContainer; + case _HitKind.chat: + label = 'Группа'; + bg = cs.secondaryContainer; + fg = cs.onSecondaryContainer; + case _HitKind.channel: + label = 'Канал'; + bg = cs.tertiaryContainer; + fg = cs.onTertiaryContainer; + case _HitKind.unknown: + label = '?'; + bg = cs.surface; + fg = cs.onSurfaceVariant; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label, + style: TextStyle(color: fg, fontSize: 10, fontWeight: FontWeight.w600), + ), + ); + } +} + +class _ErrorChip extends StatelessWidget { + final String label; + final String message; + const _ErrorChip({required this.label, required this.message}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: cs.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(Symbols.error_outline, size: 16, color: cs.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + '$label: $message', + style: TextStyle( + color: cs.onErrorContainer, + fontSize: 12, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } } \ No newline at end of file