Добавил отображение форматированных сообщений, возможность их форматировать

This commit is contained in:
jganenok
2025-12-04 21:07:23 +07:00
parent d344adf035
commit c945d57371
7 changed files with 374 additions and 105 deletions

View File

@@ -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<void> logout() async {
try {
// Удаляем текущий аккаунт из 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<void> clearAllData() async {

View File

@@ -1031,6 +1031,7 @@ extension ApiServiceChats on ApiService {
String text, {
String? replyToMessageId,
int? cid,
List<Map<String, dynamic>>? 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},

View File

@@ -9,6 +9,7 @@ class Message {
final int? cid; // клиентский id (timestamp)
final Map<String, dynamic>? reactionInfo; // Информация о реакциях
final Map<String, dynamic>? link; // Информация об ответе на сообщение
final List<Map<String, dynamic>> elements; // Форматирование текста
Message({
required this.id,
@@ -21,6 +22,7 @@ class Message {
this.cid,
this.reactionInfo,
this.link,
this.elements = const [],
});
factory Message.fromJson(Map<String, dynamic> 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<String, dynamic>())
.toList() ??
const [],
);
}
@@ -69,6 +76,7 @@ class Message {
int? cid,
Map<String, dynamic>? reactionInfo,
Map<String, dynamic>? link,
List<Map<String, dynamic>>? 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,
};
}
}

View File

@@ -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<ChatScreen> {
bool _isLoadingHistory = true;
Map<String, dynamic>? _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<ChatScreen> {
}
}
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<void> _sendMessage() async {
final originalText = _textController.text.trim();
if (originalText.isNotEmpty) {
@@ -1988,7 +2011,13 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
if (textToSend != originalText) {
_textController.elements.clear();
}
final int tempCid = DateTime.now().millisecondsSinceEpoch;
final List<Map<String, dynamic>> tempElements =
List<Map<String, dynamic>>.from(_textController.elements);
final tempMessageJson = {
'id': 'local_$tempCid',
'text': textToSend,
@@ -1997,6 +2026,7 @@ class _ChatScreenState extends State<ChatScreen> {
'cid': tempCid,
'type': 'USER',
'attaches': [],
'elements': tempElements,
'link': _replyingToMessage != null
? {
'type': 'REPLY',
@@ -2026,6 +2056,7 @@ class _ChatScreenState extends State<ChatScreen> {
textToSend,
replyToMessageId: _replyingToMessage?.id,
cid: tempCid, // Передаем тот же CID в API
elements: tempElements,
);
final readSettings = await ChatReadSettingsService.instance.getSettings(
@@ -2045,6 +2076,7 @@ class _ChatScreenState extends State<ChatScreen> {
setState(() {
_replyingToMessage = null;
_textController.elements.clear();
});
}
}
@@ -4233,7 +4265,11 @@ class _ChatScreenState extends State<ChatScreen> {
}
return KeyEventResult.ignored;
},
child: TextField(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _textController,
enabled: !isBlocked,
keyboardType: TextInputType.multiline,
@@ -4267,7 +4303,8 @@ class _ChatScreenState extends State<ChatScreen> {
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
contentPadding:
const EdgeInsets.symmetric(
horizontal: 18.0,
vertical: 12.0,
),
@@ -4275,11 +4312,69 @@ class _ChatScreenState extends State<ChatScreen> {
onChanged: isBlocked
? null
: (v) {
_resetDraftFormattingIfNeeded(v);
if (v.isNotEmpty) {
_scheduleTypingPing();
}
},
),
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: 'Зачеркнуть',
),
],
),
],
),
),
],
),

View File

@@ -3950,39 +3950,8 @@ class _ChatsScreenState extends State<ChatsScreen>
Future<void> _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()),

View File

@@ -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<void> Function(LinkableElement) onOpenLink,
) {
Future<void> Function(LinkableElement) onOpenLink, {
List<Map<String, dynamic>> 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:
final baseForSeg = seg.type == _KometSegmentType.colored
? baseStyle.copyWith(color: seg.color)
: baseStyle;
if (elements.isEmpty) {
return Linkify(
text: seg.text,
style: baseStyle.copyWith(color: seg.color),
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<Map<String, dynamic>> elements,
) {
if (text.isEmpty || elements.isEmpty) {
return Text(text, style: baseStyle);
}
final bold = List<bool>.filled(text.length, false);
final italic = List<bool>.filled(text.length, false);
final underline = List<bool>.filled(text.length, false);
final strike = List<bool>.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 = <TextSpan>[];
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 = <TextDecoration>[];
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,

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
class FormattedTextController extends TextEditingController {
final List<Map<String, dynamic>> 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<bool>.filled(text.length, false);
final italic = List<bool>.filled(text.length, false);
final underline = List<bool>.filled(text.length, false);
final strike = List<bool>.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 = <InlineSpan>[];
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 = <TextDecoration>[];
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);
}
}