From 0431db1c77f115dd6de989cbcd3e5e895719f3ad Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 15 May 2026 18:58:58 +0700 Subject: [PATCH] =?UTF-8?q?=D0=B8=D0=BD=D1=84=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 20 +- .../screens/chats/chat_info_screen.dart | 1220 +++++++++++++---- .../screens/chats/chat_list_screen.dart | 4 +- lib/frontend/screens/chats/chat_screen.dart | 178 ++- lib/frontend/widgets/message_bubble.dart | 72 +- 5 files changed, 1115 insertions(+), 379 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index b2b4b5c..6f172ae 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -278,7 +278,6 @@ class MessagesModule { // Detect CONTROL if (attachments.any((a) => a.type == AttachmentType.control)) { isControl = true; - debugPrint('CONTROL detected: ${attachments.where((a) => a.type == AttachmentType.control).first}'); } } } @@ -304,7 +303,7 @@ class MessagesModule { return int.tryParse(value.toString()) ?? 0; } - Future sendMessage( + Future sendMessage( int accountId, int chatId, String text, { @@ -321,7 +320,22 @@ class MessagesModule { 'notify': notify, }; - await _api.sendRequest(Opcode.msgSend, payload); + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) { + final msg = (response.payload is Map) + ? (response.payload['localizedMessage'] ?? response.payload['message'] ?? 'Ошибка отправки') + : 'Ошибка отправки'; + throw Exception(msg.toString()); + } + final data = response.payload; + if (data is Map) { + final msgMap = data['message']; + if (msgMap is Map) { + final id = msgMap['id']; + if (id != null) return id.toString(); + } + } + return ''; } Future requestTranscription( diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 70a4ae7..6a1277a 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -1,9 +1,28 @@ +import 'package:cached_network_image/cached_network_image.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 '../../../l10n/app_localizations.dart'; +import '../../../core/storage/app_database.dart'; import '../../../main.dart' as main; -import '../../widgets/custom_notification.dart'; + +class _MemberInfo { + final int id; + final bool isAdmin; + final bool isOwner; + final bool isMe; + final int? seenTime; + final bool isOnline; + + const _MemberInfo({ + required this.id, + required this.isAdmin, + required this.isOwner, + required this.isMe, + this.seenTime, + required this.isOnline, + }); +} class ChatInfoScreen extends StatefulWidget { final int chatId; @@ -23,362 +42,965 @@ class ChatInfoScreen extends StatefulWidget { State createState() => _ChatInfoScreenState(); } -class _ChatInfoScreenState extends State - with TickerProviderStateMixin { +class _ChatInfoScreenState extends State { + int _myId = 0; bool _isLoading = true; Map? _chatData; - late AnimationController _shimmerController; + + // DIALOG + int? _otherId; + Map? _contactData; + int? _seenTime; + bool _isOnline = false; + bool _isBot = false; + + bool _infoExpanded = false; + + // CHAT + List<_MemberInfo> _members = []; + int _onlineCount = 0; @override void initState() { super.initState(); - _shimmerController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - )..repeat(); - _loadChatData(); + _load(); } - @override - void dispose() { - _shimmerController.dispose(); - super.dispose(); - } - - Future _loadChatData() async { - try { - final packet = await main.api.sendRequest(Opcode.chatInfo, { - 'chatIds': [widget.chatId], - }); - - final payload = packet.payload as Map?; - if (payload == null) { - if (mounted) setState(() => _isLoading = false); - return; - } - - final errorField = payload['error']; - if (errorField != null) { - String errorMsg = 'Error'; - if (errorField is Map) { - errorMsg = errorField['localizedMessage'] ?? errorField['message'] ?? errorField.toString(); - } else if (errorField is String) { - errorMsg = errorField; - } - if (mounted) showCustomNotification(context, errorMsg); - setState(() => _isLoading = false); - return; - } - - final chats = payload['chats'] as List?; - if (chats != null && chats.isNotEmpty) { - _chatData = Map.from(chats.first as Map); - } else if (chats != null && chats.isEmpty) { - if (mounted) showCustomNotification(context, 'No data found'); - } + Future _load() async { + final profile = await AppDatabase.loadActiveProfile(); + _myId = profile?.id ?? 0; + final packet = await main.api.sendRequest( + Opcode.chatInfo, + {'chatIds': [widget.chatId]}, + ); + if (!packet.isOk || !mounted) { if (mounted) setState(() => _isLoading = false); - } catch (e) { - if (mounted) { - showCustomNotification(context, 'Error: $e'); - setState(() => _isLoading = false); - } + return; } + + final chats = (packet.payload as Map?)?['chats'] as List?; + if (chats == null || chats.isEmpty) { + if (mounted) setState(() => _isLoading = false); + return; + } + _chatData = Map.from(chats.first as Map); + + if (widget.chatType == 'DIALOG') { + final parts = _chatData!['participants'] as Map? ?? {}; + for (final key in parts.keys) { + final id = key is int ? key : int.tryParse(key.toString()); + if (id != null && id != _myId) { + _otherId = id; + break; + } + } + + if (_otherId != null) { + final cp = await main.api.sendRequest( + Opcode.contactInfo, + {'contactIds': [_otherId]}, + ); + if (cp.isOk) { + final contacts = (cp.payload as Map?)?['contacts'] as List?; + if (contacts != null && contacts.isNotEmpty) { + _contactData = Map.from(contacts.first as Map); + final opts = _contactData!['options']; + _isBot = (opts is List) && opts.contains('BOT'); + } + } + + final pp = await main.api.sendRequest( + Opcode.contactPresence, + {'contactIds': [_otherId]}, + ); + if (pp.isOk) { + final presence = (pp.payload as Map?)?['presence'] as Map?; + final p = presence?[_otherId.toString()] ?? presence?[_otherId]; + if (p is Map) { + _seenTime = p['seen'] as int?; + _isOnline = ((p['status'] as int?) ?? 0) > 0; + } + } + } + } else if (widget.chatType == 'CHAT') { + final parts = _chatData!['participants'] as Map? ?? {}; + final admins = _chatData!['adminParticipants'] as Map? ?? {}; + final owner = _chatData!['owner'] as int?; + + final memberIds = []; + for (final k in parts.keys) { + final id = k is int ? k : int.tryParse(k.toString()); + if (id != null) memberIds.add(id); + } + + final Map presenceMap = {}; + if (memberIds.isNotEmpty) { + final pp = await main.api.sendRequest( + Opcode.contactPresence, + {'contactIds': memberIds}, + ); + if (pp.isOk) { + final presence = (pp.payload as Map?)?['presence'] as Map?; + if (presence != null) { + for (final e in presence.entries) { + final id = e.key is int ? e.key as int : int.tryParse(e.key.toString()); + if (id != null && e.value is Map) presenceMap[id] = e.value as Map; + } + } + } + } + + _onlineCount = 0; + _members = memberIds.map((id) { + final pres = presenceMap[id]; + final online = ((pres?['status'] as int?) ?? 0) > 0; + if (online) _onlineCount++; + final isAdmin = + admins.containsKey(id.toString()) || admins.containsKey(id); + return _MemberInfo( + id: id, + isAdmin: isAdmin, + isOwner: id == owner, + isMe: id == _myId, + seenTime: pres?['seen'] as int?, + isOnline: online, + ); + }).toList(); + + _members.sort((a, b) { + if (a.isMe != b.isMe) return a.isMe ? -1 : 1; + if (a.isOnline != b.isOnline) return a.isOnline ? -1 : 1; + return (b.seenTime ?? 0).compareTo(a.seenTime ?? 0); + }); + } + + if (mounted) setState(() => _isLoading = false); } + // ─── BUILD ─────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context); + final isDark = cs.brightness == Brightness.dark; + final bg = isDark ? Colors.black : cs.surface; return Scaffold( - backgroundColor: cs.surface, - appBar: AppBar( - backgroundColor: cs.surface, - elevation: 0, - leading: IconButton( - icon: Icon(Symbols.arrow_back, color: cs.onSurface), - onPressed: () => Navigator.pop(context), - ), - title: Text( - l10n?.chatInfoTitle ?? 'Info', - style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), - ), + backgroundColor: bg, + body: SafeArea( + child: _isLoading ? _buildShimmer(cs) : _buildScrollBody(cs), ), - body: _isLoading - ? _buildShimmer(cs) - : _chatData == null - ? Center( - child: Text( - 'No data', - style: TextStyle(color: cs.onSurfaceVariant), - ), - ) - : _buildContent(cs, l10n), ); } - Widget _buildShimmer(ColorScheme cs) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Center( - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - shape: BoxShape.circle, - ), + Widget _buildScrollBody(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), ), - ), - const SizedBox(height: 12), - Center( - child: Container( - width: 120, - height: 20, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(6), - ), - ), - ), - const SizedBox(height: 24), - ...List.generate( - 10, - (_) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Container( - height: 48, - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), + actions: [ + if (widget.chatType == 'DIALOG' && !_isBot) + IconButton( + icon: Icon(Symbols.edit, color: cs.onSurface), + onPressed: () {}, ), - ), - ), + ], ), + SliverToBoxAdapter(child: _buildBody(cs)), ], ); } - Widget _buildContent(ColorScheme cs, AppLocalizations? l10n) { - final chat = _chatData!; - final type = chat['type'] as String? ?? ''; - - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Center( - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: cs.primaryContainer, - ), - child: widget.imageUrl.isNotEmpty - ? ClipOval( - child: Image.network(widget.imageUrl, fit: BoxFit.cover), - ) - : Center( - child: Text( - widget.name.isNotEmpty - ? widget.name[0].toUpperCase() - : '?', - style: TextStyle( - color: cs.onPrimaryContainer, - fontSize: 28, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - const SizedBox(height: 12), - Center( - child: Text( + Widget _buildBody(ColorScheme cs) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const SizedBox(height: 4), + _buildAvatar(cs), + const SizedBox(height: 14), + Text( widget.name, style: TextStyle( color: cs.onSurface, - fontSize: 18, - fontWeight: FontWeight.w600, + fontSize: 22, + fontWeight: FontWeight.w700, ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + _subtitle(), + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + _buildActions(cs), + const SizedBox(height: 16), + LayoutBuilder( + builder: (ctx, constraints) => + _buildInfoArea(cs, constraints.maxWidth), + ), + const SizedBox(height: 80), + ], + ), + ); + } + + // ─── AVATAR ────────────────────────────────────────────────────────────── + + Widget _buildAvatar(ColorScheme cs) { + return Container( + width: 96, + height: 96, + decoration: BoxDecoration(shape: BoxShape.circle, color: cs.primaryContainer), + child: widget.imageUrl.isNotEmpty + ? ClipOval( + child: CachedNetworkImage( + imageUrl: widget.imageUrl, + fit: BoxFit.cover, + errorWidget: (context, error, stack) => _avatarLetters(cs), + ), + ) + : _avatarLetters(cs), + ); + } + + Widget _avatarLetters(ColorScheme cs) => Center( + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 36, + fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 24), + ); - if (type == 'CHANNEL') ...[ - _buildSectionTitle('Channel', cs), - _buildRow( - l10n?.chatInfoSubscribers ?? 'subscribers:', - (chat['participantsCount'] as int?)?.toString() ?? '-', - cs, - ), - if ((chat['link'] as String?)?.isNotEmpty ?? false) - _buildRow( - l10n?.chatInfoLink ?? 'link:', - chat['link'] as String, - cs, - ), - _buildRow( - l10n?.chatInfoOfficial ?? 'official:', - (chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-', - cs, - ), - _buildRow( - l10n?.chatInfoComments ?? 'comments:', - (chat['options']?['COMMENTS'] as bool?)?.toString() ?? '-', - cs, - ), - _buildRow( - l10n?.chatInfoAplus ?? 'approved by Roskomnadzor:', - (chat['options']?['A_PLUS_CHANNEL'] as bool?)?.toString() ?? '-', - cs, - ), - _buildRow( - l10n?.chatInfoSignAdmin ?? 'admin signature:', - (chat['options']?['SIGN_ADMIN'] as bool?)?.toString() ?? '-', - cs, - ), - if ((chat['modified'] as int?) != null) - _buildRow( - l10n?.chatInfoLastChanged ?? 'last changed:', - _formatTs(chat['modified'] as int), - cs, - ), - if ((chat['created'] as int?) != null) - _buildRow( - l10n?.chatInfoCreated ?? 'created:', - _formatTs(chat['created'] as int), - cs, - ), + // ─── SUBTITLE ──────────────────────────────────────────────────────────── + + String _subtitle() { + switch (widget.chatType) { + case 'DIALOG': + if (_isBot) { + final link = _contactData?['link'] as String?; + final handle = link != null ? '@${Uri.parse(link).pathSegments.last}' : ''; + return '$handle · Бот'.trim(); + } + if (_isOnline) return 'В сети'; + if (_seenTime != null) return _formatLastSeen(_seenTime!); + return ''; + case 'CHAT': + final total = + (_chatData?['participantsCount'] as int?) ?? _members.length; + if (_onlineCount > 0) return '$_onlineCount из $total в сети'; + return _pluralCount(total, 'участник', 'участника', 'участников'); + case 'CHANNEL': + final count = (_chatData?['participantsCount'] as int?) ?? 0; + return _pluralCount(count, 'подписчик', 'подписчика', 'подписчиков'); + default: + return ''; + } + } + + // ─── ACTION BUTTONS ────────────────────────────────────────────────────── + + Widget _buildActions(ColorScheme cs) { + final List<({IconData icon, String label})> btns; + + if (widget.chatType == 'DIALOG') { + if (_isBot) { + btns = [ + (icon: Symbols.chat_bubble, label: 'Цат'), + (icon: Symbols.notifications, label: 'Звук'), + (icon: Symbols.more_horiz, label: 'Ещё'), + ]; + } else { + btns = [ + (icon: Symbols.call, label: 'Звонок'), + (icon: Symbols.videocam, label: 'Видео'), + (icon: Symbols.notifications, label: 'Звук'), + (icon: Symbols.more_horiz, label: 'Ещё'), + ]; + } + } else { + btns = [ + (icon: Symbols.notifications, label: 'Звук'), + (icon: Symbols.search, label: 'Найти'), + (icon: Symbols.more_horiz, label: 'Ещё'), + ]; + } + + return Row( + children: [ + for (int i = 0; i < btns.length; i++) ...[ + _actionBtn(cs, btns[i].icon, btns[i].label), + if (i < btns.length - 1) const SizedBox(width: 8), ], - - if (type == 'CHAT') ...[ - _buildSectionTitle('Chat', cs), - _buildRow( - l10n?.chatInfoMembers ?? 'members:', - (chat['participantsCount'] as int?)?.toString() ?? '-', - cs, - ), - if ((chat['hasBots'] as bool?) ?? false) - _buildRow( - l10n?.chatInfoHasBots ?? 'has bots:', - 'true', - cs, - ), - if ((chat['blockedParticipantsCount'] as int?) != null && - chat['blockedParticipantsCount'] > 0) - _buildRow( - l10n?.chatInfoBlockedCount ?? 'blocked in group:', - (chat['blockedParticipantsCount'] as int).toString(), - cs, - ), - _buildRow( - l10n?.chatInfoOfficialStatus ?? 'official status:', - (chat['options']?['OFFICIAL'] as bool?)?.toString() ?? '-', - cs, - ), - if ((chat['modified'] as int?) != null) - _buildRow( - l10n?.chatInfoLastChanged ?? 'last changed:', - _formatTs(chat['modified'] as int), - cs, - ), - if ((chat['joinTime'] as int?) != null && chat['joinTime'] != 1) - _buildRow( - l10n?.chatInfoJoined ?? 'joined:', - _formatTs(chat['joinTime'] as int), - cs, - ), - if ((chat['created'] as int?) != null) - _buildRow( - l10n?.chatInfoGroupCreated ?? 'group created:', - _formatTs(chat['created'] as int), - cs, - ), - if ((chat['owner'] as int?) != null) - _buildRow( - l10n?.chatInfoGroupOwner ?? 'group owner:', - (chat['owner'] as int).toString(), - cs, - ), - ], - - if (type == 'DIALOG') ...[ - if ((chat['created'] as int?) != null && - chat['created'] != 0 && - chat['created'] != 1) - _buildRow( - l10n?.chatInfoDialogStarted ?? 'dialog started:', - _formatTs(chat['created'] as int), - cs, - ), - ], - - const SizedBox(height: 120), ], ); } - Widget _buildSectionTitle(String title, ColorScheme cs) { - return Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8, left: 4, right: 4), - child: Text( - title, - style: TextStyle( - color: cs.primary, - fontSize: 13, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, + Widget _actionBtn(ColorScheme cs, IconData icon, String label) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: const Color(0xFF007AFF), size: 24), + const SizedBox(height: 5), + Text(label, style: TextStyle(color: cs.onSurface, fontSize: 12)), + ], ), ), ); } - Widget _buildRow(String label, String value, ColorScheme cs) { + // ─── SECTIONS ──────────────────────────────────────────────────────────── + + List _buildSections(ColorScheme cs) { + switch (widget.chatType) { + case 'DIALOG': + return _dialogSections(cs); + case 'CHAT': + return _groupSections(cs); + case 'CHANNEL': + return _channelSections(cs); + default: + return [_attachmentsCard(cs)]; + } + } + + List _dialogSections(ColorScheme cs) { + final result = []; + + if (_isBot) { + final link = _contactData?['link'] as String?; + if (link != null) { + result + ..add(_linkCard(cs, link)) + ..add(const SizedBox(height: 8)); + } + } else { + final phone = _contactData?['phone']; + final phoneInt = phone is int ? phone : int.tryParse(phone?.toString() ?? ''); + if (phoneInt != null && phoneInt > 0) { + result + ..add(_infoCard(cs, 'Номер телефона', _formatPhone(phoneInt))) + ..add(const SizedBox(height: 8)); + } + } + + result.add(_attachmentsCard(cs)); + return result; + } + + List _groupSections(ColorScheme cs) { + return [ + _attachmentsCard(cs), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + 'УЧАСТНИКИ', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ), + Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + _memberAction(cs, Symbols.person_add, 'Добавить участника', () {}), + _listDivider(cs), + _memberAction(cs, Symbols.link, 'Пригласить по ссылке', () {}), + ..._members.expand((m) => [_listDivider(cs), _memberTile(cs, m)]), + ], + ), + ), + ]; + } + + List _channelSections(ColorScheme cs) { + final result = []; + + final link = _chatData?['link'] as String?; + if (link != null) { + result + ..add(_linkCard(cs, link)) + ..add(const SizedBox(height: 8)); + } + + final desc = _chatData?['description'] as String?; + if (desc != null && desc.isNotEmpty) { + result + ..add(_descCard(cs, desc)) + ..add(const SizedBox(height: 8)); + } + + result.add(_attachmentsCard(cs)); + return result; + } + + // ─── CARD WIDGETS ──────────────────────────────────────────────────────── + + Widget _infoCard(ColorScheme cs, String label, String value) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + const SizedBox(height: 4), + Text(value, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500)), + ], + ), + ); + } + + Widget _linkCard(ColorScheme cs, String link) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 14), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Ссылка', + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + const SizedBox(height: 4), + Text(link, + style: const TextStyle( + color: Color(0xFF007AFF), fontSize: 15)), + ], + ), + ), + IconButton( + icon: const Icon(Symbols.share, + color: Color(0xFF007AFF), size: 22), + onPressed: () {}, + ), + IconButton( + icon: const Icon(Symbols.qr_code, + color: Color(0xFF007AFF), size: 22), + onPressed: () {}, + ), + ], + ), + ); + } + + Widget _descCard(ColorScheme cs, String desc) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Text(desc, + style: TextStyle(color: cs.onSurface, fontSize: 15, height: 1.4)), + ); + } + + Widget _attachmentsCard(ColorScheme cs) { return Container( - margin: const EdgeInsets.only(bottom: 1), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.photo_library, color: cs.onSurfaceVariant, size: 28), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Вложения', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500)), + Text('Фото, видео, файлы и ссылки', + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ], + ), + ), + Icon(Symbols.chevron_right, color: cs.onSurfaceVariant), + ], + ), + ); + } + + // ─── MEMBER LIST ───────────────────────────────────────────────────────── + + Widget _memberAction( + ColorScheme cs, IconData icon, String label, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(icon, color: const Color(0xFF007AFF), size: 26), + const SizedBox(width: 14), + Text(label, + style: TextStyle(color: cs.onSurface, fontSize: 16)), + ], + ), + ), + ); + } + + Widget _listDivider(ColorScheme cs) => Divider( + height: 1, + indent: 56, + endIndent: 0, + color: cs.outlineVariant.withValues(alpha: 0.3), + ); + + Widget _memberTile(ColorScheme cs, _MemberInfo member) { + final name = + ContactCache.get(member.id) ?? (member.isMe ? 'Вы' : '${member.id}'); + final avatar = ContactCache.getAvatar(member.id); + + final String sublabel; + if (member.isMe) { + sublabel = 'Вы'; + } else if (member.isOnline) { + sublabel = 'В сети'; + } else if (member.seenTime != null) { + sublabel = _formatLastSeen(member.seenTime!); + } else { + sublabel = 'Был(-а) недавно'; + } + + final String? roleLabel = + member.isOwner ? 'владелец' : (member.isAdmin ? 'Адмін' : null); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + (avatar != null && avatar.isNotEmpty) + ? CircleAvatar( + radius: 22, + backgroundImage: CachedNetworkImageProvider(avatar), + backgroundColor: cs.primaryContainer, + ) + : CircleAvatar( + radius: 22, + backgroundColor: cs.primaryContainer, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, fontSize: 16), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + fontWeight: FontWeight.w500)), + Text(sublabel, + style: TextStyle( + color: cs.onSurfaceVariant, fontSize: 13)), + ], + ), + ), + if (roleLabel != null) + Text(roleLabel, + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ], + ), + ); + } + + // ─── INFO AREA ─────────────────────────────────────────────────────────── + + Widget _buildInfoArea(ColorScheme cs, double W) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Кнопка "Инфо" — всегда полная ширина + GestureDetector( + onTap: () => setState(() => _infoExpanded = !_infoExpanded), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Symbols.info, color: const Color(0xFF007AFF), size: 22), + const SizedBox(width: 12), + Text( + 'Инфо', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + AnimatedRotation( + turns: _infoExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 220), + child: Icon(Symbols.keyboard_arrow_down, + color: cs.onSurfaceVariant), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + // Контент: при закрытии — обычная колонка, при открытии — Row + AnimatedCrossFade( + duration: const Duration(milliseconds: 250), + sizeCurve: Curves.easeOut, + firstCurve: Curves.easeOut, + secondCurve: Curves.easeOut, + crossFadeState: _infoExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildSections(cs), + ), + secondChild: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildCompactSections(cs)), + const SizedBox(width: 8), + Expanded(child: _buildInfoPanelCard(cs)), + ], + ), + ), + ], + ); + } + + // Компактные карточки — левая колонка при открытом инфо + Widget _buildCompactSections(ColorScheme cs) { + final items = []; + + if (widget.chatType == 'DIALOG') { + if (_isBot) { + final link = _contactData?['link'] as String?; + if (link != null) items.add(_compactCard(cs, 'Ссылка', link)); + } else { + final phone = _contactData?['phone']; + final phoneInt = + phone is int ? phone : int.tryParse(phone?.toString() ?? ''); + if (phoneInt != null && phoneInt > 0) { + items.add(_compactCard(cs, 'Телефон', _formatPhone(phoneInt))); + } + } + } + + if (widget.chatType == 'CHANNEL') { + final desc = _chatData?['description'] as String?; + if (desc != null && desc.isNotEmpty) { + items.add(_compactCard( + cs, + 'Описание', + desc.length > 80 ? '${desc.substring(0, 80)}…' : desc, + )); + } + } + + if (widget.chatType == 'CHAT') { + final total = (_chatData?['participantsCount'] as int?) ?? _members.length; + items.add(_compactCard(cs, 'Участников', '$total')); + } + + items.add(const SizedBox(height: 8)); + items.add(_compactAttachments(cs)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (int i = 0; i < items.length; i++) ...[ + items[i], + if (i < items.length - 1 && items[i] is! SizedBox) + const SizedBox(height: 8), + ], + ], + ); + } + + Widget _compactCard(ColorScheme cs, String label, String value) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11)), + const SizedBox(height: 3), + Text(value, + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w500), + maxLines: 4, + overflow: TextOverflow.ellipsis), + ], + ), + ); + } + + Widget _compactAttachments(ColorScheme cs) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ + Icon(Symbols.photo_library, color: cs.onSurfaceVariant, size: 20), + const SizedBox(width: 8), Expanded( - flex: 2, - child: Text( - label, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 3, - child: Text( - value, - style: TextStyle( - color: cs.onSurface, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.end, - ), + child: Text('Вложения', + style: TextStyle( + color: cs.onSurface, + fontSize: 13, + fontWeight: FontWeight.w500)), ), + Icon(Symbols.chevron_right, color: cs.onSurfaceVariant, size: 18), ], ), ); } - String _formatTs(int ts) { - if (ts < 1000000000000) return ts.toString(); - final dt = DateTime.fromMillisecondsSinceEpoch(ts); - return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' - '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; + // Инфо-панель — правая колонка при открытом инфо + Widget _buildInfoPanelCard(ColorScheme cs) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + child: _buildAllInfoRows(cs), + ); } -} \ No newline at end of file + + Widget _buildAllInfoRows(ColorScheme cs) { + final rows = <({String label, String value})>[]; + final chat = _chatData; + if (chat == null) { + return Text('Нет данных', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)); + } + + void add(String label, dynamic val, {bool tsFormat = false}) { + if (val == null) return; + if (val is bool && !val) return; + String str; + if (tsFormat && val is int && val > 1) { + str = _formatTs(val); + } else if (val is bool) { + str = 'да'; + } else { + str = val.toString(); + } + if (str.isEmpty) return; + rows.add((label: label, value: str)); + } + + final type = widget.chatType; + add('ID чата', chat['id']); + + if (type == 'DIALOG') { + add('Создан', chat['created'], tsFormat: true); + add('Изменён', chat['modified'], tsFormat: true); + add('Статус', chat['status']); + } + + if (type == 'CHAT') { + add('Участников', chat['participantsCount']); + final owner = chat['owner'] as int?; + if (owner != null && owner != 0) { + add('Владелец', ContactCache.get(owner) ?? '$owner'); + } + add('Создана', chat['created'], tsFormat: true); + add('Вступил', (chat['joinTime'] as int?) != null && (chat['joinTime'] as int) > 1 + ? chat['joinTime'] : null, tsFormat: true); + add('Изменена', chat['modified'], tsFormat: true); + add('Есть боты', chat['hasBots'] as bool?); + final blocked = chat['blockedParticipantsCount'] as int?; + if (blocked != null && blocked > 0) add('Заблокировано', blocked); + final opts = chat['options'] as Map?; + add('Официальная', opts?['OFFICIAL'] as bool?); + add('Подпись адм.', opts?['SIGN_ADMIN'] as bool?); + add('Статус', chat['status']); + } + + if (type == 'CHANNEL') { + add('Подписчиков', chat['participantsCount']); + add('Создан', chat['created'], tsFormat: true); + add('Изменён', chat['modified'], tsFormat: true); + final opts = chat['options'] as Map?; + add('Официальный', opts?['OFFICIAL'] as bool?); + add('Комментарии', opts?['COMMENTS'] as bool?); + add('РКН', opts?['A_PLUS_CHANNEL'] as bool?); + add('Подпись адм.', opts?['SIGN_ADMIN'] as bool?); + add('Только адм.', opts?['ONLY_ADMIN_CAN_ADD_MEMBER'] as bool?); + add('Статус', chat['status']); + } + + if (rows.isEmpty) { + return Text('Нет данных', + 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)), + ], + ], + ); + } + + Widget _infoRow(ColorScheme cs, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + 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)), + ], + ), + ); + } + + // ─── SHIMMER ───────────────────────────────────────────────────────────── + + Widget _buildShimmer(ColorScheme cs) { + Widget block(double w, double h, {double r = 8}) => Container( + width: w, + height: h, + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(r), + ), + ); + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 60, 16, 0), + children: [ + Center( + child: block(96, 96, r: 48)), + const SizedBox(height: 14), + Center(child: block(160, 22, r: 8)), + const SizedBox(height: 8), + Center(child: block(110, 16, r: 6)), + const SizedBox(height: 24), + block(double.infinity, 70, r: 14), + const SizedBox(height: 12), + block(double.infinity, 70, r: 14), + const SizedBox(height: 12), + block(double.infinity, 70, r: 14), + ], + ); + } + + // ─── HELPERS ───────────────────────────────────────────────────────────── + + String _formatLastSeen(int ms) { + final diff = DateTime.now().millisecondsSinceEpoch - ms; + if (diff < 60000) return 'только что'; + if (diff < 3600000) return '${diff ~/ 60000} мин назад'; + if (diff < 86400000) return '${diff ~/ 3600000} ч назад'; + if (diff < 604800000) return '${diff ~/ 86400000} д назад'; + return 'давно'; + } + + String _formatPhone(int phone) { + final s = phone.toString(); + if (s.length == 11 && s.startsWith('7')) { + return '+7 ${s.substring(1, 4)} ${s.substring(4, 7)}-' + '${s.substring(7, 9)}-${s.substring(9, 11)}'; + } + return '+$s'; + } + + String _formatTs(int ts) { + final dt = DateTime.fromMillisecondsSinceEpoch(ts); + return '${dt.day.toString().padLeft(2, '0')}.${dt.month.toString().padLeft(2, '0')}.${dt.year} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + String _pluralCount(int n, String one, String few, String many) { + final mod100 = n % 100; + final mod10 = n % 10; + if (mod100 >= 11 && mod100 <= 14) return '$n $many'; + if (mod10 == 1) return '$n $one'; + if (mod10 >= 2 && mod10 <= 4) return '$n $few'; + return '$n $many'; + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 906dadc..13cf4f7 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -787,7 +787,7 @@ class _ChatListScreenState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 2), + padding: const EdgeInsets.fromLTRB(20, 6, 20, 3), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -933,7 +933,7 @@ class _ChatListScreenState extends State ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), child: Container( height: 44, decoration: BoxDecoration( diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index e37aca3..e6c7c8c 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:komet/backend/modules/chats.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -51,7 +52,6 @@ class _ChatScreenState extends State final GlobalKey _listKey = GlobalKey(); bool _hasText = false; bool _isLoading = true; - bool _isSending = false; bool _showAttachmentPanel = false; late AnimationController _shimmerController; List _messages = []; @@ -64,6 +64,7 @@ class _ChatScreenState extends State late final AnimationController _floatingDateAnimController; final Map _separatorKeys = {}; double _lastScrollOffset = 0; + String? _lastSentId; @override void initState() { @@ -87,10 +88,10 @@ class _ChatScreenState extends State final activeProfile = await AppDatabase.loadActiveProfile(); _myId = activeProfile?.id ?? 0; ChatsModule.getChat(_myId, widget.chatId).then((value) { - chat = value[0]; - }).catchError((error) { - - }); + if (mounted && value.isNotEmpty) { + setState(() { chat = value.first; }); + } + }).catchError((_) {}); final cachedRows = await AppDatabase.loadMessages( _myId, @@ -155,17 +156,29 @@ class _ChatScreenState extends State } } + String? _effectiveStatus(CachedMessage msg) { + if (msg.senderId != _myId) return null; + if (msg.status == 'sending' || msg.status == 'error') return msg.status; + final c = chat; + if (c == null) return 'sent'; + int otherReadTime = 0; + for (final entry in c.participants.entries) { + if (entry.key != _myId && entry.value > otherReadTime) { + otherReadTime = entry.value; + } + } + if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read'; + return 'sent'; + } + Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; - setState(() { - _isSending = true; - }); + final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; + final now = DateTime.now().millisecondsSinceEpoch; try { - final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}'; - final now = DateTime.now().millisecondsSinceEpoch; final tempMessage = CachedMessage( id: tempId, @@ -178,6 +191,7 @@ class _ChatScreenState extends State ); setState(() { + _lastSentId = tempId; _messages.add(tempMessage); _messageController.clear(); _hasText = false; @@ -189,13 +203,13 @@ class _ChatScreenState extends State _scrollToBottom(); - await messagesModule.sendMessage(_myId, widget.chatId, text); + final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text); final index = _messages.indexWhere((m) => m.id == tempId); - if (index != -1) { + if (index != -1 && mounted) { setState(() { _messages[index] = CachedMessage( - id: tempId, + id: actualId.isNotEmpty ? actualId : tempId, accountId: _myId, chatId: widget.chatId, senderId: _myId, @@ -206,12 +220,21 @@ class _ChatScreenState extends State }); } } catch (e) { - debugPrint('Error sending message: $e'); Haptics.error(); - } finally { - setState(() { - _isSending = false; - }); + final index = _messages.indexWhere((m) => m.id == tempId); + if (index != -1 && mounted) { + setState(() { + _messages[index] = CachedMessage( + id: tempId, + accountId: _myId, + chatId: widget.chatId, + senderId: _myId, + text: text, + time: now, + status: 'error', + ); + }); + } } } @@ -429,7 +452,7 @@ class _ChatScreenState extends State // TODO: Локализация // TODO: Cклонения - String? status = chat?.type == "CHAT" ? "${chat?.participants.length.toString()} участников" : "last seen recently"; + final String status = chat?.type == "CHAT" ? "${chat?.participants.length ?? 0} участников" : "last seen recently"; return Scaffold( backgroundColor: cs.surface, appBar: PreferredSize( @@ -505,7 +528,7 @@ class _ChatScreenState extends State ], ), Text( - status ?? "", + status, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 12, @@ -590,8 +613,6 @@ class _ChatScreenState extends State final msgItem = item as _MessageItem; final message = msgItem.message; final msgIndex = msgItem.index; - debugPrint( - 'LIST_ITEM: ${message.id} isControl=${message.isControl} hasAttach=${message.attachments != null}'); final isMe = message.senderId == _myId; final prevMessage = msgIndex > 0 ? _messages[msgIndex - 1] : null; @@ -599,14 +620,26 @@ class _ChatScreenState extends State ? _messages[msgIndex + 1] : null; - return MessageBubble( + final bubble = MessageBubble( message: message, isMe: isMe, myId: _myId, prevMessage: prevMessage, nextMessage: nextMessage, chatType: chat?.type ?? 'CHAT', + overrideStatus: _effectiveStatus(message), ); + + if (isMe && message.id == _lastSentId) { + return _SentMessageAnimation( + key: ValueKey('anim_${message.id}'), + onComplete: () { + if (mounted) setState(() => _lastSentId = null); + }, + child: bubble, + ); + } + return bubble; }, ), if (_lastFloatingDate != null) @@ -805,22 +838,33 @@ class _ChatScreenState extends State Icon(Symbols.face, color: mutedIcon, size: 24, weight: 400), const SizedBox(width: 12), Expanded( - child: TextField( - controller: _messageController, - style: TextStyle(color: cs.onSurface, fontSize: 16), - maxLines: null, - keyboardType: TextInputType.multiline, - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - hintText: 'Message', - hintStyle: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 16, - ), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - vertical: 14, + child: Focus( + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter && + !HardwareKeyboard.instance.isShiftPressed) { + if (_hasText) _sendMessage(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: _messageController, + style: TextStyle(color: cs.onSurface, fontSize: 16), + maxLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hintText: 'Message', + hintStyle: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + ), ), ), ), @@ -892,3 +936,59 @@ class _ChatScreenState extends State ); } } + +class _SentMessageAnimation extends StatefulWidget { + final Widget child; + final VoidCallback onComplete; + + const _SentMessageAnimation({ + super.key, + required this.child, + required this.onComplete, + }); + + @override + State<_SentMessageAnimation> createState() => _SentMessageAnimationState(); +} + +class _SentMessageAnimationState extends State<_SentMessageAnimation> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _opacity; + late final Animation _slide; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 220), + ); + _opacity = CurvedAnimation(parent: _ctrl, curve: Curves.easeOut); + _slide = Tween(begin: 16, end: 0).animate( + CurvedAnimation(parent: _ctrl, curve: Curves.easeOut), + ); + _ctrl.forward().whenComplete(widget.onComplete); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (context, child) => Opacity( + opacity: _opacity.value, + child: Transform.translate( + offset: Offset(0, _slide.value), + child: child, + ), + ), + child: widget.child, + ); + } +} diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index fdbe793..c723f53 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -44,6 +44,7 @@ class MessageBubble extends StatelessWidget { final CachedMessage? prevMessage; final CachedMessage? nextMessage; final String chatType; + final String? overrideStatus; const MessageBubble({ super.key, @@ -52,7 +53,8 @@ class MessageBubble extends StatelessWidget { required this.myId, this.prevMessage, this.nextMessage, - required this.chatType + required this.chatType, + this.overrideStatus, }); bool get isGroupedWithNext { @@ -315,7 +317,6 @@ class MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { if (message.isControl) { - debugPrint('BUILD CONTROL: ${message.id}'); return Padding( padding: EdgeInsets.only(top: topMargin, bottom: bottomMargin), child: Center(child: _buildControlContent(context)), @@ -490,7 +491,7 @@ class MessageBubble extends StatelessWidget { children: [ Flexible( child: isForwarded - ? _buildForwardedInlineText(context, forwarded, textColor) + ? _buildForwardedInlineText(context, forwarded!, textColor) : Text( message.text ?? '', style: TextStyle(color: textColor, fontSize: 16, height: 1.3), @@ -500,7 +501,9 @@ class MessageBubble extends StatelessWidget { Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( - _formatTime(message.time), + message.status == 'EDITED' + ? '${_formatTime(message.time)} ред.' + : _formatTime(message.time), style: TextStyle( color: textColor.withValues(alpha: 0.7), fontSize: 10, @@ -904,13 +907,6 @@ class MessageBubble extends StatelessWidget { ForwardedMessageAttachment forwarded, List attachments, ) { - debugPrint('DEBUG: _buildForwardedGenericContent called'); - debugPrint('DEBUG: attachments count: ${attachments.length}'); - for (var i = 0; i < attachments.length; i++) { - debugPrint('DEBUG: attachment[$i] type: ${attachments[i].runtimeType}'); - debugPrint('DEBUG: attachment[$i] type field: ${attachments[i].type}'); - } - final cs = Theme.of(context).colorScheme; final headerColor = isMe ? Colors.white.withValues(alpha: 0.7) @@ -1671,7 +1667,7 @@ class MessageBubble extends StatelessWidget { url: url, textColor: textColor, isMe: isMe, - status: message.status, + status: overrideStatus ?? message.status, time: message.time, cs: cs, waveData: waveData, @@ -1729,31 +1725,31 @@ class MessageBubble extends StatelessWidget { } Widget _buildStatusIcon(BuildContext context) { - final status = message.status; + final status = overrideStatus ?? message.status; IconData icon; Color color; - if (status == null || status == 'sending' || status == 'pending') { - icon = Symbols.check; - color = Colors.white70; - } else { - switch (status) { - case 'sent': - icon = Symbols.check; - color = Colors.white70; - case 'delivered': - icon = Symbols.done_all; - color = Colors.white70; - case 'read': - icon = Symbols.done_all; - color = const Color(0xFF34C759); - case 'error': - icon = Symbols.error; - color = Colors.redAccent; - default: - icon = Symbols.check; - color = Colors.white70; - } + switch (status) { + case 'sending': + case 'pending': + icon = Symbols.schedule; + color = Colors.white70; + case null: + case 'sent': + icon = Symbols.check; + color = Colors.white70; + case 'delivered': + icon = Symbols.done_all; + color = Colors.white70; + case 'read': + icon = Symbols.done_all; + color = const Color(0xFF4FC3F7); + case 'error': + icon = Symbols.error; + color = Colors.redAccent; + default: + icon = Symbols.check; + color = Colors.white70; } return Icon(icon, size: 14, color: color); @@ -1840,11 +1836,15 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { IconData icon; Color color; - if (status == null || status == 'sending' || status == 'pending') { + if (status == null || status == 'sent') { icon = Symbols.check; color = Colors.white54; } else { switch (status) { + case 'sending': + case 'pending': + icon = Symbols.schedule; + color = Colors.white54; case 'sent': icon = Symbols.check; color = Colors.white54; @@ -1853,7 +1853,7 @@ class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> { color = Colors.white54; case 'read': icon = Symbols.done_all; - color = const Color(0xFF34C759); + color = const Color(0xFF4FC3F7); case 'error': icon = Symbols.error; color = Colors.redAccent;