ДАНОН ПРАБИФ ДОКС СВЯТ ПРОФИЛЕЙ

This commit is contained in:
Jganenok
2026-05-17 21:24:59 +07:00
parent 4987ae30ab
commit 78fa0251c5
6 changed files with 1198 additions and 60 deletions
+13
View File
@@ -471,6 +471,19 @@ class AppDatabase {
);
}
static Future<int?> 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<List<Map<String, dynamic>>> loadDialogChats(int accountId) async {
final db = await _instance;
return db.query(
+110 -21
View File
@@ -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<ChatInfoScreen> {
int _myId = 0;
bool _isLoading = true;
bool _extraContactExpanded = false;
Map<String, dynamic>? _chatData;
String _selectedTab = '';
bool _descExpanded = false;
@@ -921,34 +923,121 @@ class _ChatInfoScreenState extends State<ChatInfoScreen> {
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<String>().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,
],
),
);
@@ -270,6 +270,15 @@ class _ChatScreenState extends State<ChatScreen>
);
});
}
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);
@@ -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<ContactProfileScreen> createState() => _ContactProfileScreenState();
}
class _ContactProfileScreenState extends State<ContactProfileScreen> {
bool _loading = true;
Map<String, dynamic>? _contact;
int? _seenTime;
bool _isOnline = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<String, dynamic>.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<String> _options() {
final raw = _contact?['options'];
if (raw is List) return raw.whereType<String>().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<void> _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 = <Widget>[];
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,
),
],
),
),
],
),
);
}
}
+207 -2
View File
@@ -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<ContactsTab> {
_loadContacts();
}
Future<void> _openSearchById() async {
final cs = Theme.of(context).colorScheme;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: cs.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => const _SearchContactSheet(),
);
}
Future<void> _loadContacts() async {
final p = await AppDatabase.loadActiveProfile();
if (p == null) {
@@ -67,7 +84,16 @@ class _ContactsTabState extends State<ContactsTab> {
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<ContactsTab> {
),
IconButton(
icon: Icon(Symbols.search, color: cs.onSurface),
onPressed: () {},
onPressed: _openSearchById,
),
],
),
@@ -216,3 +242,182 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
}
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<void> _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<String, dynamic>.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('Найти'),
),
],
),
),
),
);
}
}
@@ -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<DebugMenuScreen> {
final _idController = TextEditingController();
String? _searchResult;
bool _isSearching = false;
bool _hasSearched = false;
final List<_SearchHit> _hits = [];
final Map<String, String> _errors = {};
@override
void dispose() {
@@ -27,26 +34,57 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
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<void> tryProbe(String label, Future<dynamic> 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<DebugMenuScreen> {
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<DebugMenuScreen> {
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<DebugMenuScreen> {
),
],
),
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<DebugMenuScreen> {
),
);
}
}
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<String>().toSet()
: <String>{};
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,
),
),
],
),
);
}
}