ДАНОН ПРАБИФ ДОКС СВЯТ ПРОФИЛЕЙ
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user