From da22b0fbf39cc0beb73b693379a0a2650b064a4a Mon Sep 17 00:00:00 2001 From: Jganenok Date: Sun, 17 May 2026 23:26:50 +0700 Subject: [PATCH] =?UTF-8?q?=D0=BD=D0=B5=D0=BC=D0=BD=D0=BE=D0=B6=D0=BA?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B0=D0=BB=20=D1=82=D0=B0=D0=B9=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=B2=20chat=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/chats.dart | 36 ++++ lib/core/protocol/packet.dart | 4 +- .../screens/chats/chat_info_screen.dart | 16 +- lib/frontend/screens/chats/chat_screen.dart | 197 +++++++++++++++++- .../contacts/contact_profile_screen.dart | 7 +- .../screens/profile/settings_tab.dart | 143 +++++++++++-- lib/main.dart | 2 + 7 files changed, 366 insertions(+), 39 deletions(-) diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index b1f4359..5cc7cca 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -153,6 +153,42 @@ class ChatsModule { static final ValueNotifier chatsChanged = ValueNotifier(0); static void _bump() => chatsChanged.value = chatsChanged.value + 1; + static StreamSubscription? _globalPushSub; + + static void attachGlobalPushHandlers(Api api) { + _globalPushSub?.cancel(); + _globalPushSub = api.pushStream.listen(_handleGlobalPush); + } + + static Future _handleGlobalPush(Packet packet) async { + switch (packet.opcode) { + case Opcode.notifMark: + await _handleNotifMark(packet); + } + } + + static Future _handleNotifMark(Packet packet) async { + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId is! int) return; + final userId = payload['userId']; + if (userId is! int) return; + final mark = payload['mark']; + if (mark is! int) return; + if (payload['setAsUnread'] == true) return; + + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId == null) return; + + final rows = await AppDatabase.loadChat(accountId, chatId); + if (rows.isEmpty) return; + final cached = CachedChat.fromDbRow(rows.first); + if (cached.participants[userId] == mark) return; + cached.participants[userId] = mark; + await AppDatabase.saveChats([cached.toDbRow()]); + } + static final Set _pendingContactUpdates = {}; static Timer? _contactFlushTimer; static Future? _contactFlushFuture; diff --git a/lib/core/protocol/packet.dart b/lib/core/protocol/packet.dart index d8527a1..592ddd2 100644 --- a/lib/core/protocol/packet.dart +++ b/lib/core/protocol/packet.dart @@ -10,8 +10,8 @@ const int _maxDecompressedSize = 1048576; // 1 MB /// Типы команд в протоколе abstract class CmdType { - static const int request = 0; // запрос клиента - static const int push = 1; // пуш от сервера + static const int request = 0; // запрос клиента / пуш от сервера (направление определяет смысл) + static const int push = 0; // пуш от сервера (имеет смысл только для incoming) static const int ok = 1; // ответ: ок static const int notFound = 2; // ответ: не найдено diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index 777c32c..ef5a480 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -58,6 +58,7 @@ class _ChatInfoScreenState extends State { Map? _contactData; int? _seenTime; bool _isOnline = false; + int _presenceStatus = 0; bool _isBot = false; // CHAT @@ -144,7 +145,9 @@ class _ChatInfoScreenState extends State { final p = presence?[_otherId.toString()] ?? presence?[_otherId]; if (p is Map) { _seenTime = p['seen'] as int?; - _isOnline = ((p['status'] as int?) ?? 0) > 0; + final st = (p['status'] as int?) ?? 0; + _presenceStatus = st; + _isOnline = st == 1; } } } @@ -183,7 +186,7 @@ class _ChatInfoScreenState extends State { _onlineCount = 0; _members = memberIds.map((id) { final pres = presenceMap[id]; - final online = ((pres?['status'] as int?) ?? 0) > 0; + final online = (pres?['status'] as int?) == 1; if (online) _onlineCount++; final isAdmin = admins.containsKey(id.toString()) || admins.containsKey(id); @@ -328,7 +331,10 @@ class _ChatInfoScreenState extends State { case 'DIALOG': if (_isBot) return 'Бот'; if (_isOnline) return 'В сети'; - if (_seenTime != null) return _formatLastSeen(_seenTime!); + if (_presenceStatus == 3) return 'был(-а) недавно'; + if (_seenTime != null && _seenTime! > 0) { + return 'был(-а) ${_formatLastSeen(_seenTime!)}'; + } return ''; case 'CHAT': final total = @@ -1075,8 +1081,8 @@ class _ChatInfoScreenState extends State { // ─── HELPERS ───────────────────────────────────────────────────────────── - String _formatLastSeen(int ms) { - final diff = DateTime.now().millisecondsSinceEpoch - ms; + String _formatLastSeen(int secondsSinceEpoch) { + final diff = DateTime.now().millisecondsSinceEpoch - secondsSinceEpoch * 1000; if (diff < 60000) return 'только что'; if (diff < 3600000) return '${diff ~/ 60000} мин назад'; if (diff < 86400000) return '${diff ~/ 3600000} ч назад'; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 003b00a..c45bf37 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -12,6 +12,8 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; +import '../../../core/protocol/opcode_map.dart'; +import '../../../core/protocol/packet.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/haptics.dart'; import '../../../core/config/app_cache_extent.dart'; @@ -75,6 +77,12 @@ class _ChatScreenState extends State final ValueNotifier _showAttachmentPanel = ValueNotifier(false); final ValueNotifier<_UploadStatus> _uploadStatus = ValueNotifier(const _UploadStatus()); StreamSubscription? _uploadSub; + StreamSubscription? _pushSub; + final Set _typingUserIds = {}; + final Map _typingTimers = {}; + int _otherStatus = 0; + int? _otherSeenTime; + final ValueNotifier _headerStatusNotifier = ValueNotifier(''); int _tempIdCounter = 0; late final AnimationController _attachAnim; @@ -107,6 +115,12 @@ class _ChatScreenState extends State reverseDuration: const Duration(milliseconds: 240), ); _showAttachmentPanel.addListener(_onAttachPanelToggle); + _pushSub = api.pushStream + .where((p) => + p.opcode == Opcode.notifMessage || + p.opcode == Opcode.notifMark || + p.opcode == Opcode.notifTyping) + .listen(_onIncomingPush); _floatingDateAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), @@ -127,8 +141,12 @@ class _ChatScreenState extends State ChatsModule.getChat(_myId, widget.chatId).then((value) { if (mounted && value.isNotEmpty) { setState(() { chat = value.first; }); + _recomputeHeaderStatus(); } }).catchError((_) {}); + if (widget.chatType == 'DIALOG') { + unawaited(_loadOtherPresence()); + } final cachedRows = await AppDatabase.loadMessages( _myId, @@ -184,6 +202,12 @@ class _ChatScreenState extends State _showAttachmentPanel.removeListener(_onAttachPanelToggle); _showAttachmentPanel.dispose(); _uploadSub?.cancel(); + _pushSub?.cancel(); + for (final t in _typingTimers.values) { + t.cancel(); + } + _typingTimers.clear(); + _headerStatusNotifier.dispose(); _uploadStatus.dispose(); _attachAnim.dispose(); _messageController.dispose(); @@ -222,6 +246,161 @@ class _ChatScreenState extends State return 'sent'; } + void _onIncomingPush(Packet packet) { + if (!mounted) return; + switch (packet.opcode) { + case Opcode.notifMessage: + _onIncomingMessage(packet); + case Opcode.notifMark: + _onMessageRead(packet); + case Opcode.notifTyping: + _onTyping(packet); + } + } + + Future _loadOtherPresence() async { + if (_myId == 0) return; + final otherId = widget.chatId ^ _myId; + if (otherId <= 0) return; + try { + final p = await api.sendRequest( + Opcode.contactPresence, + {'contactIds': [otherId]}, + ); + if (!mounted) return; + final presence = (p.payload as Map?)?['presence'] as Map?; + final entry = presence?[otherId.toString()] ?? presence?[otherId]; + if (entry is Map) { + _otherStatus = (entry['status'] as int?) ?? 0; + _otherSeenTime = entry['seen'] as int?; + _recomputeHeaderStatus(); + } + } catch (_) {} + } + + String _formatLastSeen(int secondsSinceEpoch) { + final dt = DateTime.fromMillisecondsSinceEpoch(secondsSinceEpoch * 1000); + final diff = DateTime.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} дн назад'; + const months = [ + 'янв', 'фев', 'мар', 'апр', 'мая', 'июн', + 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек', + ]; + return 'Был(-а) ${dt.day} ${months[dt.month - 1]} ${dt.year}'; + } + + void _recomputeHeaderStatus() { + _headerStatusNotifier.value = _headerStatus(); + } + + String _headerStatus() { + if (_typingUserIds.isNotEmpty) return 'Печатает...'; + if (widget.chatType == 'CHAT') { + return '${chat?.participants.length ?? 0} участников'; + } + if (widget.chatType == 'CHANNEL') { + return '${chat?.participants.length ?? 0} подписчиков'; + } + if (_otherStatus == 1) return 'В сети'; + if (_otherStatus == 3) return 'Был(-а) недавно'; + final s = _otherSeenTime; + if (s != null && s > 0) return _formatLastSeen(s); + return ''; + } + + void _onTyping(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + if (payload['chatId'] != widget.chatId) return; + final userId = payload['userId']; + if (userId is! int || userId == _myId) return; + + _typingTimers[userId]?.cancel(); + _typingTimers[userId] = Timer(const Duration(seconds: 10), () { + if (!mounted) return; + _typingUserIds.remove(userId); + _typingTimers.remove(userId); + _recomputeHeaderStatus(); + }); + if (_typingUserIds.add(userId)) { + _recomputeHeaderStatus(); + } + } + + void _clearTyping(int userId) { + _typingTimers.remove(userId)?.cancel(); + if (_typingUserIds.remove(userId)) { + _recomputeHeaderStatus(); + } + } + + void _onMessageRead(Packet packet) { + final payload = packet.payload; + if (payload is! Map) return; + if (payload['chatId'] != widget.chatId) return; + final userId = payload['userId']; + if (userId is! int || userId == _myId) return; + final mark = payload['mark']; + if (mark is! int) return; + if (payload['setAsUnread'] == true) return; + final c = chat; + if (c == null) return; + if (c.participants[userId] == mark) return; + setState(() { + c.participants[userId] = mark; + }); + } + + void _onIncomingMessage(Packet packet) { + if (!mounted) return; + final payload = packet.payload; + if (payload is! Map) return; + final chatId = payload['chatId']; + if (chatId != widget.chatId) return; + final msg = payload['message']; + if (msg is! Map) return; + + final senderId = msg['sender']; + if (senderId is! int) return; + if (senderId == _myId) return; + + final msgId = msg['id']?.toString(); + if (msgId == null || msgId.isEmpty) return; + if (_messages.any((m) => m.id == msgId)) return; + + List? attachments; + final attaches = msg['attaches']; + if (attaches is List && attaches.isNotEmpty) { + attachments = attaches + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + + final cached = CachedMessage( + id: msgId, + accountId: _myId, + chatId: widget.chatId, + senderId: senderId, + text: msg['text'] as String?, + time: (msg['time'] as int?) ?? DateTime.now().millisecondsSinceEpoch, + status: 'sent', + payload: Map.from(msg), + attachments: attachments, + ); + + setState(() { + _lastSentId = msgId; + _messages.add(cached); + }); + _clearTyping(senderId); + Haptics.tap(); + _scrollToBottom(); + } + Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _myId == 0) return; @@ -508,7 +687,6 @@ class _ChatScreenState extends State // TODO: Локализация // TODO: Cклонения - final String status = chat?.type == "CHAT" ? "${chat?.participants.length ?? 0} участников" : "last seen recently"; return Scaffold( backgroundColor: cs.surface, appBar: PreferredSize( @@ -583,12 +761,15 @@ class _ChatScreenState extends State ], ], ), - Text( - status, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.w400, + ValueListenableBuilder( + valueListenable: _headerStatusNotifier, + builder: (context, status, _) => Text( + status, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w400, + ), ), ), ], @@ -697,7 +878,7 @@ class _ChatScreenState extends State overrideStatus: _effectiveStatus(message), ); - if (isMe && message.id == _lastSentId) { + if (message.id == _lastSentId) { return _SentMessageAnimation( key: ValueKey('anim_${message.id}'), onComplete: () { diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index 034674b..e0051b4 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -29,7 +29,7 @@ class _ContactProfileScreenState extends State { bool _loading = true; Map? _contact; int? _seenTime; - bool _isOnline = false; + int _presenceStatus = 0; @override void initState() { @@ -57,7 +57,7 @@ class _ContactProfileScreenState extends State { 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; + _presenceStatus = (p['status'] as int?) ?? 0; } } } catch (e) { @@ -101,7 +101,8 @@ class _ContactProfileScreenState extends State { String _subtitle() { if (_isBot) return 'Бот'; - if (_isOnline) return 'В сети'; + if (_presenceStatus == 1) return 'В сети'; + if (_presenceStatus == 3) return 'Был(-а) недавно'; if (_seenTime != null && _seenTime! > 0) return _formatLastSeen(_seenTime!); return ''; } diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index f93a07b..53eb24c 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -5,9 +5,11 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/storage/app_database.dart'; +import '../../../core/storage/token_storage.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; import 'customization_screen.dart'; import 'performance_screen.dart'; @@ -26,6 +28,8 @@ class SettingsTab extends StatefulWidget { } class _SettingsTabState extends State { + static const bool _showLogoutButton = false; + ProfileData? _profile; bool _isPhoneVisible = false; String? _appVersionLabel; @@ -94,6 +98,83 @@ class _SettingsTabState extends State { if (mounted) setState(() => _hapticsEnabled = value); } + Future _confirmLogout() async { + final cs = Theme.of(context).colorScheme; + final confirmed = await showModalBottomSheet( + context: context, + backgroundColor: cs.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Выйти из аккаунта?', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Сессия будет сброшена. Локальный кеш сохранится — войдёшь снова в этот же аккаунт.', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: cs.error, + foregroundColor: cs.onError, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: const Text('Выйти'), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Отмена'), + ), + ], + ), + ), + ); + }, + ); + if (confirmed != true || !mounted) return; + await _doLogout(); + } + + Future _doLogout() async { + final navState = KometApp.navigatorKey.currentState; + try { + await api.disconnect(); + } catch (_) {} + final accountId = await TokenStorage.getActiveAccountId(); + if (accountId != null) { + await TokenStorage.deleteToken(accountId); + } + try { + await api.connect(); + } catch (_) {} + if (navState != null) { + await navState.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (route) => false, + ); + } + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; @@ -426,31 +507,51 @@ child: _buildSection( ), ), const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.center, + Stack( children: [ - GestureDetector( - onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: _PhoneSpoiler( - text: phone, - isVisible: _isPhoneVisible, - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - letterSpacing: 0.5, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () => setState(() => _isPhoneVisible = !_isPhoneVisible), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: _PhoneSpoiler( + text: phone, + isVisible: _isPhoneVisible, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.5, + ), + ), + ), + ), + const SizedBox(width: 4), + Icon( + _isPhoneVisible ? Symbols.visibility : Symbols.visibility_off, + size: 14, + color: cs.onSurfaceVariant.withValues(alpha: 0.6), + ), + ], + ), + if (_showLogoutButton) + Positioned.fill( + child: Align( + alignment: Alignment.centerRight, + child: IconButton( + tooltip: 'Выйти', + icon: Icon( + Symbols.logout, + color: cs.error, + size: 22, + weight: 400, + ), + onPressed: _confirmLogout, ), ), ), - ), - const SizedBox(width: 4), - Icon( - _isPhoneVisible ? Symbols.visibility : Symbols.visibility_off, - size: 14, - color: cs.onSurfaceVariant.withValues(alpha: 0.6), - ), ], ), ], diff --git a/lib/main.dart b/lib/main.dart index 951c017..a342bbf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'core/config/app_bubble_shape.dart'; import 'core/config/app_cache_extent.dart'; import 'core/config/app_fonts.dart'; import 'backend/modules/account.dart'; +import 'backend/modules/chats.dart'; import 'backend/modules/contacts.dart'; import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; @@ -53,6 +54,7 @@ void main() async { if (activeAccountId != null) { await ContactsModule.primeCacheFromDb(activeAccountId); } + ChatsModule.attachGlobalPushHandlers(api); await api.connect(); final packageInfo = await PackageInfo.fromPlatform();