Files
Qlyra/lib/frontend/screens/contacts/contact_profile_screen.dart
T

449 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/token_storage.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/swipe_route.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;
int _presenceStatus = 0;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final results = await Future.wait([
ContactInfoFetch.get(widget.contactId),
PresenceFetch.get(widget.contactId),
]);
if (!mounted) return;
final contact = results[0];
if (contact != null) {
_contact = contact;
}
final presence = results[1];
if (presence != null) {
_seenTime = presence['seen'] as int?;
_presenceStatus = (presence['status'] as int?) ?? 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 (_presenceStatus == 1) return 'В сети';
if (_presenceStatus == 3) 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;
pushSwipeable(
context,
(_) => 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,
),
],
),
),
],
),
);
}
}