diff --git a/lib/api/api_service_auth.dart b/lib/api/api_service_auth.dart index cb50244..8ef458d 100644 --- a/lib/api/api_service_auth.dart +++ b/lib/api/api_service_auth.dart @@ -129,7 +129,6 @@ extension ApiServiceAuth on ApiService { await prefs.setString('userId', userId); } - // Полный сброс сессии как при переключении аккаунта _messageQueue.clear(); _lastChatsPayload = null; _chatsFetchedInThisSession = false; @@ -231,9 +230,37 @@ extension ApiServiceAuth on ApiService { Future logout() async { try { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove('authToken'); - await prefs.remove('userId'); + // Удаляем текущий аккаунт из AccountManager / prefs + final accountManager = AccountManager(); + await accountManager.initialize(); + final currentAccount = accountManager.currentAccount; + + if (currentAccount != null) { + try { + if (accountManager.accounts.length > 1) { + await accountManager.removeAccount(currentAccount.id); + } else { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('authToken'); + await prefs.remove('userId'); + await prefs.remove('multi_accounts'); + await prefs.remove('current_account_id'); + } + } catch (e) { + print('Ошибка при удалении аккаунта: $e'); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('authToken'); + await prefs.remove('userId'); + } + } else { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('authToken'); + await prefs.remove('userId'); + await prefs.remove('multi_accounts'); + await prefs.remove('current_account_id'); + } + + // Чистим in-memory состояние и разрываем соединение authToken = null; userId = null; _messageCache.clear(); @@ -242,7 +269,20 @@ extension ApiServiceAuth on ApiService { _pingTimer?.cancel(); await _channel?.sink.close(status.goingAway); _channel = null; - } catch (_) {} + + clearAllCaches(); + + _isSessionOnline = false; + _isSessionReady = false; + _handshakeSent = false; + _reconnectAttempts = 0; + _currentUrlIndex = 0; + + _messageQueue.clear(); + _presenceData.clear(); + } catch (e) { + print('Ошибка logout(): $e'); + } } Future clearAllData() async { diff --git a/lib/api/api_service_chats.dart b/lib/api/api_service_chats.dart index a3f3d66..52f1bae 100644 --- a/lib/api/api_service_chats.dart +++ b/lib/api/api_service_chats.dart @@ -1031,6 +1031,7 @@ extension ApiServiceChats on ApiService { String text, { String? replyToMessageId, int? cid, + List>? elements, }) { final int clientMessageId = cid ?? DateTime.now().millisecondsSinceEpoch; final payload = { @@ -1038,7 +1039,7 @@ extension ApiServiceChats on ApiService { "message": { "text": text, "cid": clientMessageId, - "elements": [], + "elements": elements ?? [], "attaches": [], if (replyToMessageId != null) "link": {"type": "REPLY", "messageId": replyToMessageId}, diff --git a/lib/models/message.dart b/lib/models/message.dart index 316250e..cb33268 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -9,6 +9,7 @@ class Message { final int? cid; // клиентский id (timestamp) final Map? reactionInfo; // Информация о реакциях final Map? link; // Информация об ответе на сообщение + final List> elements; // Форматирование текста Message({ required this.id, @@ -21,6 +22,7 @@ class Message { this.cid, this.reactionInfo, this.link, + this.elements = const [], }); factory Message.fromJson(Map json) { @@ -55,6 +57,11 @@ class Message { cid: json['cid'], reactionInfo: json['reactionInfo'], link: json['link'], + elements: + (json['elements'] as List?) + ?.map((e) => (e as Map).cast()) + .toList() ?? + const [], ); } @@ -69,6 +76,7 @@ class Message { int? cid, Map? reactionInfo, Map? link, + List>? elements, }) { return Message( id: id ?? this.id, @@ -81,6 +89,7 @@ class Message { cid: cid ?? this.cid, reactionInfo: reactionInfo ?? this.reactionInfo, link: link ?? this.link, + elements: elements ?? this.elements, ); } @@ -117,6 +126,7 @@ class Message { 'attaches': attaches, 'link': link, 'reactionInfo': reactionInfo, + 'elements': elements, }; } } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index cfb9168..db3f21b 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -32,6 +32,7 @@ import 'package:gwid/screens/chat_encryption_settings_screen.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:gwid/services/chat_encryption_service.dart'; import 'package:lottie/lottie.dart'; +import 'package:gwid/widgets/formatted_text_controller.dart'; bool _debugShowExactDate = false; @@ -230,7 +231,7 @@ class _ChatScreenState extends State { bool _isLoadingHistory = true; Map? _emptyChatSticker; - final TextEditingController _textController = TextEditingController(); + final FormattedTextController _textController = FormattedTextController(); final FocusNode _textFocusNode = FocusNode(); StreamSubscription? _apiSubscription; final ItemScrollController _itemScrollController = ItemScrollController(); @@ -1946,6 +1947,28 @@ class _ChatScreenState extends State { } } + void _applyTextFormat(String type) { + final selection = _textController.selection; + if (!selection.isValid || selection.isCollapsed) return; + final from = selection.start; + final length = selection.end - selection.start; + if (length <= 0) return; + + setState(() { + _textController.elements.add({ + 'type': type, + 'from': from, + 'length': length, + }); + }); + } + + void _resetDraftFormattingIfNeeded(String newText) { + if (newText.isEmpty) { + _textController.elements.clear(); + } + } + Future _sendMessage() async { final originalText = _textController.text.trim(); if (originalText.isNotEmpty) { @@ -1988,7 +2011,13 @@ class _ChatScreenState extends State { ); } + if (textToSend != originalText) { + _textController.elements.clear(); + } + final int tempCid = DateTime.now().millisecondsSinceEpoch; + final List> tempElements = + List>.from(_textController.elements); final tempMessageJson = { 'id': 'local_$tempCid', 'text': textToSend, @@ -1997,6 +2026,7 @@ class _ChatScreenState extends State { 'cid': tempCid, 'type': 'USER', 'attaches': [], + 'elements': tempElements, 'link': _replyingToMessage != null ? { 'type': 'REPLY', @@ -2026,6 +2056,7 @@ class _ChatScreenState extends State { textToSend, replyToMessageId: _replyingToMessage?.id, cid: tempCid, // Передаем тот же CID в API + elements: tempElements, ); final readSettings = await ChatReadSettingsService.instance.getSettings( @@ -2045,6 +2076,7 @@ class _ChatScreenState extends State { setState(() { _replyingToMessage = null; + _textController.elements.clear(); }); } } @@ -4233,52 +4265,115 @@ class _ChatScreenState extends State { } return KeyEventResult.ignored; }, - child: TextField( - controller: _textController, - enabled: !isBlocked, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - minLines: 1, - maxLines: 5, - decoration: InputDecoration( - hintText: isBlocked - ? 'Пользователь заблокирован' - : 'Сообщение...', - filled: true, - isDense: true, - fillColor: isBlocked - ? Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withOpacity(0.25) - : Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withOpacity(0.4), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _textController, + enabled: !isBlocked, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + minLines: 1, + maxLines: 5, + decoration: InputDecoration( + hintText: isBlocked + ? 'Пользователь заблокирован' + : 'Сообщение...', + filled: true, + isDense: true, + fillColor: isBlocked + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + contentPadding: + const EdgeInsets.symmetric( + horizontal: 18.0, + vertical: 12.0, + ), + ), + onChanged: isBlocked + ? null + : (v) { + _resetDraftFormattingIfNeeded(v); + if (v.isNotEmpty) { + _scheduleTypingPing(); + } + }, ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + iconSize: 18, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.format_bold), + onPressed: isBlocked + ? null + : () => _applyTextFormat('STRONG'), + tooltip: 'Жирный', + ), + IconButton( + iconSize: 18, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.format_italic), + onPressed: isBlocked + ? null + : () => _applyTextFormat( + 'EMPHASIZED', + ), + tooltip: 'Курсив', + ), + IconButton( + iconSize: 18, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: const Icon( + Icons.format_underline, + ), + onPressed: isBlocked + ? null + : () => + _applyTextFormat('UNDERLINE'), + tooltip: 'Подчеркнуть', + ), + IconButton( + iconSize: 18, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: const Icon( + Icons.format_strikethrough, + ), + onPressed: isBlocked + ? null + : () => _applyTextFormat( + 'STRIKETHROUGH', + ), + tooltip: 'Зачеркнуть', + ), + ], ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 18.0, - vertical: 12.0, - ), - ), - onChanged: isBlocked - ? null - : (v) { - if (v.isNotEmpty) { - _scheduleTypingPing(); - } - }, + ], ), ), ], diff --git a/lib/screens/chats_screen.dart b/lib/screens/chats_screen.dart index 2525aba..751af6b 100644 --- a/lib/screens/chats_screen.dart +++ b/lib/screens/chats_screen.dart @@ -3950,39 +3950,8 @@ class _ChatsScreenState extends State Future _logout() async { try { - ApiService.instance.disconnect(); - - final accountManager = AccountManager(); - await accountManager.initialize(); - final currentAccount = accountManager.currentAccount; - - if (currentAccount != null) { - try { - if (accountManager.accounts.length > 1) { - await accountManager.removeAccount(currentAccount.id); - } else { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove('authToken'); - await prefs.remove('userId'); - await prefs.remove('multi_accounts'); - await prefs.remove('current_account_id'); - } - } catch (e) { - print('Ошибка при удалении аккаунта: $e'); - final prefs = await SharedPreferences.getInstance(); - await prefs.remove('authToken'); - await prefs.remove('userId'); - } - } else { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove('authToken'); - await prefs.remove('userId'); - } - await ApiService.instance.logout(); - ApiService.instance.clearAllCaches(); - if (mounted) { Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute(builder: (context) => const PhoneEntryScreen()), diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 95bfb1e..787e8ab 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -4097,6 +4097,7 @@ class ChatMessageBubble extends StatelessWidget { defaultTextStyle, linkStyle, onOpenLink, + elements: message.elements, ) else if (message.text.contains("welcome.saved.dialog.message")) Linkify( @@ -4115,15 +4116,15 @@ class ChatMessageBubble extends StatelessWidget { defaultTextStyle, linkStyle, onOpenLink, + elements: message.elements, ) else - Linkify( - text: message.text, - style: defaultTextStyle, - linkStyle: linkStyle, - onOpen: onOpenLink, - options: const LinkifyOptions(humanize: false), - textAlign: TextAlign.left, + _buildMixedMessageContent( + message.text, + defaultTextStyle, + linkStyle, + onOpenLink, + elements: message.elements, ), if (message.reactionInfo != null) const SizedBox(height: 4), ], @@ -4380,8 +4381,9 @@ class ChatMessageBubble extends StatelessWidget { String text, TextStyle baseStyle, TextStyle linkStyle, - Future Function(LinkableElement) onOpenLink, - ) { + Future Function(LinkableElement) onOpenLink, { + List> elements = const [], + }) { final segments = _parseMixedMessageSegments(text); return Wrap( @@ -4389,21 +4391,22 @@ class ChatMessageBubble extends StatelessWidget { children: segments.map((seg) { switch (seg.type) { case _KometSegmentType.normal: - return Linkify( - text: seg.text, - style: baseStyle, - linkStyle: linkStyle, - onOpen: onOpenLink, - options: const LinkifyOptions(humanize: false), - ); case _KometSegmentType.colored: - return Linkify( - text: seg.text, - style: baseStyle.copyWith(color: seg.color), - linkStyle: linkStyle, - onOpen: onOpenLink, - options: const LinkifyOptions(humanize: false), - ); + final baseForSeg = seg.type == _KometSegmentType.colored + ? baseStyle.copyWith(color: seg.color) + : baseStyle; + + if (elements.isEmpty) { + return Linkify( + text: seg.text, + style: baseForSeg, + linkStyle: linkStyle, + onOpen: onOpenLink, + options: const LinkifyOptions(humanize: false), + ); + } else { + return _buildFormattedRichText(seg.text, baseForSeg, elements); + } case _KometSegmentType.galaxy: return _GalaxyAnimatedText(text: seg.text); case _KometSegmentType.pulse: @@ -4444,6 +4447,78 @@ class ChatMessageBubble extends StatelessWidget { ); } + /// Строит RichText с учётом elements (STRONG, EMPHASIZED, UNDERLINE, STRIKETHROUGH). + Widget _buildFormattedRichText( + String text, + TextStyle baseStyle, + List> elements, + ) { + if (text.isEmpty || elements.isEmpty) { + return Text(text, style: baseStyle); + } + + final bold = List.filled(text.length, false); + final italic = List.filled(text.length, false); + final underline = List.filled(text.length, false); + final strike = List.filled(text.length, false); + + for (final el in elements) { + final type = el['type'] as String?; + final from = (el['from'] as int?) ?? 0; + final length = (el['length'] as int?) ?? 0; + if (type == null || length <= 0) continue; + final start = from.clamp(0, text.length); + final end = (from + length).clamp(0, text.length); + for (int i = start; i < end; i++) { + switch (type) { + case 'STRONG': + bold[i] = true; + break; + case 'EMPHASIZED': + italic[i] = true; + break; + case 'UNDERLINE': + underline[i] = true; + break; + case 'STRIKETHROUGH': + strike[i] = true; + break; + } + } + } + + final spans = []; + int start = 0; + + TextStyle _styleForIndex(int i) { + var s = baseStyle; + if (bold[i]) s = s.copyWith(fontWeight: FontWeight.w600); + if (italic[i]) s = s.copyWith(fontStyle: FontStyle.italic); + final line = []; + if (underline[i]) line.add(TextDecoration.underline); + if (strike[i]) line.add(TextDecoration.lineThrough); + if (line.isNotEmpty) { + s = s.copyWith(decoration: TextDecoration.combine(line)); + } + return s; + } + + while (start < text.length) { + int end = start + 1; + final style = _styleForIndex(start); + while (end < text.length && _styleForIndex(end) == style) { + end++; + } + spans.add(TextSpan(text: text.substring(start, end), style: style)); + start = end; + } + + return RichText( + textAlign: TextAlign.left, + text: TextSpan(children: spans, style: baseStyle), + ); + } + List<_KometColoredSegment> _parseKometColorSegments( String text, Color? fallbackColor, diff --git a/lib/widgets/formatted_text_controller.dart b/lib/widgets/formatted_text_controller.dart new file mode 100644 index 0000000..416fe8e --- /dev/null +++ b/lib/widgets/formatted_text_controller.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +class FormattedTextController extends TextEditingController { + final List> elements = []; + + FormattedTextController({String? text}) : super(text: text); + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + bool withComposing = false, + }) { + final baseStyle = style ?? DefaultTextStyle.of(context).style; + final text = value.text; + + if (text.isEmpty || elements.isEmpty) { + return TextSpan(text: text, style: baseStyle); + } + + final bold = List.filled(text.length, false); + final italic = List.filled(text.length, false); + final underline = List.filled(text.length, false); + final strike = List.filled(text.length, false); + + for (final el in elements) { + final type = el['type'] as String?; + final from = (el['from'] as int?) ?? 0; + final length = (el['length'] as int?) ?? 0; + if (type == null || length <= 0) continue; + final start = from.clamp(0, text.length); + final end = (from + length).clamp(0, text.length); + for (int i = start; i < end; i++) { + switch (type) { + case 'STRONG': + bold[i] = true; + break; + case 'EMPHASIZED': + italic[i] = true; + break; + case 'UNDERLINE': + underline[i] = true; + break; + case 'STRIKETHROUGH': + strike[i] = true; + break; + } + } + } + + final spans = []; + int start = 0; + + TextStyle styleForIndex(int i) { + var s = baseStyle; + if (bold[i]) s = s.copyWith(fontWeight: FontWeight.w600); + if (italic[i]) s = s.copyWith(fontStyle: FontStyle.italic); + final decos = []; + if (underline[i]) decos.add(TextDecoration.underline); + if (strike[i]) decos.add(TextDecoration.lineThrough); + if (decos.isNotEmpty) { + s = s.copyWith(decoration: TextDecoration.combine(decos)); + } + return s; + } + + while (start < text.length) { + int end = start + 1; + final base = styleForIndex(start); + while (end < text.length && styleForIndex(end) == base) { + end++; + } + spans.add(TextSpan(text: text.substring(start, end), style: base)); + start = end; + } + + return TextSpan(style: baseStyle, children: spans); + } +}