Добавил отображение форматированных сообщений, возможность их форматировать
This commit is contained in:
@@ -129,7 +129,6 @@ extension ApiServiceAuth on ApiService {
|
|||||||
await prefs.setString('userId', userId);
|
await prefs.setString('userId', userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Полный сброс сессии как при переключении аккаунта
|
|
||||||
_messageQueue.clear();
|
_messageQueue.clear();
|
||||||
_lastChatsPayload = null;
|
_lastChatsPayload = null;
|
||||||
_chatsFetchedInThisSession = false;
|
_chatsFetchedInThisSession = false;
|
||||||
@@ -231,9 +230,37 @@ extension ApiServiceAuth on ApiService {
|
|||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
try {
|
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();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove('authToken');
|
await prefs.remove('authToken');
|
||||||
await prefs.remove('userId');
|
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;
|
authToken = null;
|
||||||
userId = null;
|
userId = null;
|
||||||
_messageCache.clear();
|
_messageCache.clear();
|
||||||
@@ -242,7 +269,20 @@ extension ApiServiceAuth on ApiService {
|
|||||||
_pingTimer?.cancel();
|
_pingTimer?.cancel();
|
||||||
await _channel?.sink.close(status.goingAway);
|
await _channel?.sink.close(status.goingAway);
|
||||||
_channel = null;
|
_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 {
|
Future<void> clearAllData() async {
|
||||||
|
|||||||
@@ -1031,6 +1031,7 @@ extension ApiServiceChats on ApiService {
|
|||||||
String text, {
|
String text, {
|
||||||
String? replyToMessageId,
|
String? replyToMessageId,
|
||||||
int? cid,
|
int? cid,
|
||||||
|
List<Map<String, dynamic>>? elements,
|
||||||
}) {
|
}) {
|
||||||
final int clientMessageId = cid ?? DateTime.now().millisecondsSinceEpoch;
|
final int clientMessageId = cid ?? DateTime.now().millisecondsSinceEpoch;
|
||||||
final payload = {
|
final payload = {
|
||||||
@@ -1038,7 +1039,7 @@ extension ApiServiceChats on ApiService {
|
|||||||
"message": {
|
"message": {
|
||||||
"text": text,
|
"text": text,
|
||||||
"cid": clientMessageId,
|
"cid": clientMessageId,
|
||||||
"elements": [],
|
"elements": elements ?? [],
|
||||||
"attaches": [],
|
"attaches": [],
|
||||||
if (replyToMessageId != null)
|
if (replyToMessageId != null)
|
||||||
"link": {"type": "REPLY", "messageId": replyToMessageId},
|
"link": {"type": "REPLY", "messageId": replyToMessageId},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class Message {
|
|||||||
final int? cid; // клиентский id (timestamp)
|
final int? cid; // клиентский id (timestamp)
|
||||||
final Map<String, dynamic>? reactionInfo; // Информация о реакциях
|
final Map<String, dynamic>? reactionInfo; // Информация о реакциях
|
||||||
final Map<String, dynamic>? link; // Информация об ответе на сообщение
|
final Map<String, dynamic>? link; // Информация об ответе на сообщение
|
||||||
|
final List<Map<String, dynamic>> elements; // Форматирование текста
|
||||||
|
|
||||||
Message({
|
Message({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -21,6 +22,7 @@ class Message {
|
|||||||
this.cid,
|
this.cid,
|
||||||
this.reactionInfo,
|
this.reactionInfo,
|
||||||
this.link,
|
this.link,
|
||||||
|
this.elements = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Message.fromJson(Map<String, dynamic> json) {
|
factory Message.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -55,6 +57,11 @@ class Message {
|
|||||||
cid: json['cid'],
|
cid: json['cid'],
|
||||||
reactionInfo: json['reactionInfo'],
|
reactionInfo: json['reactionInfo'],
|
||||||
link: json['link'],
|
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,
|
int? cid,
|
||||||
Map<String, dynamic>? reactionInfo,
|
Map<String, dynamic>? reactionInfo,
|
||||||
Map<String, dynamic>? link,
|
Map<String, dynamic>? link,
|
||||||
|
List<Map<String, dynamic>>? elements,
|
||||||
}) {
|
}) {
|
||||||
return Message(
|
return Message(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -81,6 +89,7 @@ class Message {
|
|||||||
cid: cid ?? this.cid,
|
cid: cid ?? this.cid,
|
||||||
reactionInfo: reactionInfo ?? this.reactionInfo,
|
reactionInfo: reactionInfo ?? this.reactionInfo,
|
||||||
link: link ?? this.link,
|
link: link ?? this.link,
|
||||||
|
elements: elements ?? this.elements,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +126,7 @@ class Message {
|
|||||||
'attaches': attaches,
|
'attaches': attaches,
|
||||||
'link': link,
|
'link': link,
|
||||||
'reactionInfo': reactionInfo,
|
'reactionInfo': reactionInfo,
|
||||||
|
'elements': elements,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import 'package:gwid/screens/chat_encryption_settings_screen.dart';
|
|||||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||||
import 'package:gwid/services/chat_encryption_service.dart';
|
import 'package:gwid/services/chat_encryption_service.dart';
|
||||||
import 'package:lottie/lottie.dart';
|
import 'package:lottie/lottie.dart';
|
||||||
|
import 'package:gwid/widgets/formatted_text_controller.dart';
|
||||||
|
|
||||||
bool _debugShowExactDate = false;
|
bool _debugShowExactDate = false;
|
||||||
|
|
||||||
@@ -230,7 +231,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
bool _isLoadingHistory = true;
|
bool _isLoadingHistory = true;
|
||||||
Map<String, dynamic>? _emptyChatSticker;
|
Map<String, dynamic>? _emptyChatSticker;
|
||||||
final TextEditingController _textController = TextEditingController();
|
final FormattedTextController _textController = FormattedTextController();
|
||||||
final FocusNode _textFocusNode = FocusNode();
|
final FocusNode _textFocusNode = FocusNode();
|
||||||
StreamSubscription? _apiSubscription;
|
StreamSubscription? _apiSubscription;
|
||||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
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 {
|
Future<void> _sendMessage() async {
|
||||||
final originalText = _textController.text.trim();
|
final originalText = _textController.text.trim();
|
||||||
if (originalText.isNotEmpty) {
|
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 int tempCid = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final List<Map<String, dynamic>> tempElements =
|
||||||
|
List<Map<String, dynamic>>.from(_textController.elements);
|
||||||
final tempMessageJson = {
|
final tempMessageJson = {
|
||||||
'id': 'local_$tempCid',
|
'id': 'local_$tempCid',
|
||||||
'text': textToSend,
|
'text': textToSend,
|
||||||
@@ -1997,6 +2026,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
'cid': tempCid,
|
'cid': tempCid,
|
||||||
'type': 'USER',
|
'type': 'USER',
|
||||||
'attaches': [],
|
'attaches': [],
|
||||||
|
'elements': tempElements,
|
||||||
'link': _replyingToMessage != null
|
'link': _replyingToMessage != null
|
||||||
? {
|
? {
|
||||||
'type': 'REPLY',
|
'type': 'REPLY',
|
||||||
@@ -2026,6 +2056,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
textToSend,
|
textToSend,
|
||||||
replyToMessageId: _replyingToMessage?.id,
|
replyToMessageId: _replyingToMessage?.id,
|
||||||
cid: tempCid, // Передаем тот же CID в API
|
cid: tempCid, // Передаем тот же CID в API
|
||||||
|
elements: tempElements,
|
||||||
);
|
);
|
||||||
|
|
||||||
final readSettings = await ChatReadSettingsService.instance.getSettings(
|
final readSettings = await ChatReadSettingsService.instance.getSettings(
|
||||||
@@ -2045,6 +2076,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_replyingToMessage = null;
|
_replyingToMessage = null;
|
||||||
|
_textController.elements.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4233,7 +4265,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
},
|
},
|
||||||
child: TextField(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
controller: _textController,
|
controller: _textController,
|
||||||
enabled: !isBlocked,
|
enabled: !isBlocked,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
@@ -4267,7 +4303,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(
|
||||||
horizontal: 18.0,
|
horizontal: 18.0,
|
||||||
vertical: 12.0,
|
vertical: 12.0,
|
||||||
),
|
),
|
||||||
@@ -4275,11 +4312,69 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
onChanged: isBlocked
|
onChanged: isBlocked
|
||||||
? null
|
? null
|
||||||
: (v) {
|
: (v) {
|
||||||
|
_resetDraftFormattingIfNeeded(v);
|
||||||
if (v.isNotEmpty) {
|
if (v.isNotEmpty) {
|
||||||
_scheduleTypingPing();
|
_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: 'Зачеркнуть',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3950,39 +3950,8 @@ class _ChatsScreenState extends State<ChatsScreen>
|
|||||||
|
|
||||||
Future<void> _logout() async {
|
Future<void> _logout() async {
|
||||||
try {
|
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();
|
await ApiService.instance.logout();
|
||||||
|
|
||||||
ApiService.instance.clearAllCaches();
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
MaterialPageRoute(builder: (context) => const PhoneEntryScreen()),
|
MaterialPageRoute(builder: (context) => const PhoneEntryScreen()),
|
||||||
|
|||||||
@@ -4097,6 +4097,7 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
defaultTextStyle,
|
defaultTextStyle,
|
||||||
linkStyle,
|
linkStyle,
|
||||||
onOpenLink,
|
onOpenLink,
|
||||||
|
elements: message.elements,
|
||||||
)
|
)
|
||||||
else if (message.text.contains("welcome.saved.dialog.message"))
|
else if (message.text.contains("welcome.saved.dialog.message"))
|
||||||
Linkify(
|
Linkify(
|
||||||
@@ -4115,15 +4116,15 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
defaultTextStyle,
|
defaultTextStyle,
|
||||||
linkStyle,
|
linkStyle,
|
||||||
onOpenLink,
|
onOpenLink,
|
||||||
|
elements: message.elements,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Linkify(
|
_buildMixedMessageContent(
|
||||||
text: message.text,
|
message.text,
|
||||||
style: defaultTextStyle,
|
defaultTextStyle,
|
||||||
linkStyle: linkStyle,
|
linkStyle,
|
||||||
onOpen: onOpenLink,
|
onOpenLink,
|
||||||
options: const LinkifyOptions(humanize: false),
|
elements: message.elements,
|
||||||
textAlign: TextAlign.left,
|
|
||||||
),
|
),
|
||||||
if (message.reactionInfo != null) const SizedBox(height: 4),
|
if (message.reactionInfo != null) const SizedBox(height: 4),
|
||||||
],
|
],
|
||||||
@@ -4380,8 +4381,9 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
String text,
|
String text,
|
||||||
TextStyle baseStyle,
|
TextStyle baseStyle,
|
||||||
TextStyle linkStyle,
|
TextStyle linkStyle,
|
||||||
Future<void> Function(LinkableElement) onOpenLink,
|
Future<void> Function(LinkableElement) onOpenLink, {
|
||||||
) {
|
List<Map<String, dynamic>> elements = const [],
|
||||||
|
}) {
|
||||||
final segments = _parseMixedMessageSegments(text);
|
final segments = _parseMixedMessageSegments(text);
|
||||||
|
|
||||||
return Wrap(
|
return Wrap(
|
||||||
@@ -4389,21 +4391,22 @@ class ChatMessageBubble extends StatelessWidget {
|
|||||||
children: segments.map((seg) {
|
children: segments.map((seg) {
|
||||||
switch (seg.type) {
|
switch (seg.type) {
|
||||||
case _KometSegmentType.normal:
|
case _KometSegmentType.normal:
|
||||||
return Linkify(
|
|
||||||
text: seg.text,
|
|
||||||
style: baseStyle,
|
|
||||||
linkStyle: linkStyle,
|
|
||||||
onOpen: onOpenLink,
|
|
||||||
options: const LinkifyOptions(humanize: false),
|
|
||||||
);
|
|
||||||
case _KometSegmentType.colored:
|
case _KometSegmentType.colored:
|
||||||
|
final baseForSeg = seg.type == _KometSegmentType.colored
|
||||||
|
? baseStyle.copyWith(color: seg.color)
|
||||||
|
: baseStyle;
|
||||||
|
|
||||||
|
if (elements.isEmpty) {
|
||||||
return Linkify(
|
return Linkify(
|
||||||
text: seg.text,
|
text: seg.text,
|
||||||
style: baseStyle.copyWith(color: seg.color),
|
style: baseForSeg,
|
||||||
linkStyle: linkStyle,
|
linkStyle: linkStyle,
|
||||||
onOpen: onOpenLink,
|
onOpen: onOpenLink,
|
||||||
options: const LinkifyOptions(humanize: false),
|
options: const LinkifyOptions(humanize: false),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
return _buildFormattedRichText(seg.text, baseForSeg, elements);
|
||||||
|
}
|
||||||
case _KometSegmentType.galaxy:
|
case _KometSegmentType.galaxy:
|
||||||
return _GalaxyAnimatedText(text: seg.text);
|
return _GalaxyAnimatedText(text: seg.text);
|
||||||
case _KometSegmentType.pulse:
|
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(
|
List<_KometColoredSegment> _parseKometColorSegments(
|
||||||
String text,
|
String text,
|
||||||
Color? fallbackColor,
|
Color? fallbackColor,
|
||||||
|
|||||||
79
lib/widgets/formatted_text_controller.dart
Normal file
79
lib/widgets/formatted_text_controller.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user