форматирование сообщений
This commit is contained in:
@@ -11,6 +11,7 @@ import '../../core/cache/message_session_cache.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../core/utils/text_format.dart';
|
||||
import '../api.dart';
|
||||
import 'folders.dart';
|
||||
import 'messages.dart' show ContactCache, CachedMessage;
|
||||
@@ -40,6 +41,7 @@ class CachedChat {
|
||||
final int? lastMsgTime;
|
||||
final String? lastMsgText;
|
||||
final String? lastMsgTextOneLine;
|
||||
final String? lastMsgElements;
|
||||
final int? lastMsgSenderId;
|
||||
final String? lastMsgStatus;
|
||||
final int unreadCount;
|
||||
@@ -63,6 +65,7 @@ class CachedChat {
|
||||
this.lastMsgId,
|
||||
this.lastMsgTime,
|
||||
this.lastMsgText,
|
||||
this.lastMsgElements,
|
||||
this.lastMsgSenderId,
|
||||
this.lastMsgStatus,
|
||||
required this.unreadCount,
|
||||
@@ -82,6 +85,16 @@ class CachedChat {
|
||||
|
||||
bool get isOfficial => options.contains('OFFICIAL');
|
||||
|
||||
List<FormatRange> get lastMsgFormatRanges {
|
||||
final raw = lastMsgElements;
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
try {
|
||||
return parseFormatElements(jsonDecode(raw));
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
bool get lastMsgReadByOthers {
|
||||
final t = lastMsgTime;
|
||||
if (t == null) return false;
|
||||
@@ -108,6 +121,7 @@ class CachedChat {
|
||||
lastMsgId: row['last_msg_id'] as int?,
|
||||
lastMsgTime: row['last_msg_time'] as int?,
|
||||
lastMsgText: row['last_msg_text'] as String?,
|
||||
lastMsgElements: row['last_msg_elements'] as String?,
|
||||
lastMsgSenderId: row['last_msg_sender'] as int?,
|
||||
lastMsgStatus: row['last_msg_status'] as String?,
|
||||
unreadCount: row['unread_count'] as int,
|
||||
@@ -146,6 +160,7 @@ class CachedChat {
|
||||
'last_msg_id': lastMsgId,
|
||||
'last_msg_time': lastMsgTime,
|
||||
'last_msg_text': lastMsgText,
|
||||
'last_msg_elements': lastMsgElements,
|
||||
'last_msg_sender': lastMsgSenderId,
|
||||
'last_msg_status': lastMsgStatus,
|
||||
'unread_count': unreadCount,
|
||||
@@ -290,6 +305,14 @@ class ChatsModule {
|
||||
return attachPreviewLabel(msg['attaches']);
|
||||
}
|
||||
|
||||
static String? messagePreviewElements(Map msg) {
|
||||
final text = msg['text'];
|
||||
if (text is! String || text.isEmpty) return null;
|
||||
final elements = msg['elements'];
|
||||
if (elements is List && elements.isNotEmpty) return jsonEncode(elements);
|
||||
return null;
|
||||
}
|
||||
|
||||
static final _messageEventsController =
|
||||
StreamController<MessageEvent>.broadcast();
|
||||
static Stream<MessageEvent> get messageEvents =>
|
||||
@@ -365,6 +388,7 @@ class ChatsModule {
|
||||
required int time,
|
||||
required String text,
|
||||
required String status,
|
||||
List<Map<String, dynamic>>? elements,
|
||||
}) async {
|
||||
final rows = await AppDatabase.loadChat(accountId, chatId);
|
||||
if (rows.isEmpty) return;
|
||||
@@ -375,6 +399,9 @@ class ChatsModule {
|
||||
if (time < existingTime && existingId != thisId) return;
|
||||
row['last_msg_id'] = thisId;
|
||||
row['last_msg_text'] = text;
|
||||
row['last_msg_elements'] = (elements != null && elements.isNotEmpty)
|
||||
? jsonEncode(elements)
|
||||
: null;
|
||||
row['last_msg_time'] = time;
|
||||
row['last_event_time'] = time;
|
||||
row['last_msg_sender'] = accountId;
|
||||
@@ -618,6 +645,7 @@ class ChatsModule {
|
||||
}
|
||||
}
|
||||
newRow['last_msg_text'] = messagePreviewText(msg);
|
||||
newRow['last_msg_elements'] = messagePreviewElements(msg);
|
||||
if (senderId != null) newRow['last_msg_sender'] = senderId;
|
||||
newRow['last_msg_status'] = 'sent';
|
||||
}
|
||||
@@ -642,8 +670,10 @@ class ChatsModule {
|
||||
final newRow = Map<String, dynamic>.from(chatRow);
|
||||
if (latest.isNotEmpty) {
|
||||
final m = latest.first;
|
||||
String? previewText = m['text']?.toString();
|
||||
if (previewText == null || previewText.isEmpty) {
|
||||
final rawText = m['text']?.toString();
|
||||
String? previewText = rawText;
|
||||
String? elementsJson;
|
||||
if (rawText == null || rawText.isEmpty) {
|
||||
final payloadRaw = m['payload'];
|
||||
if (payloadRaw is String && payloadRaw.isNotEmpty) {
|
||||
try {
|
||||
@@ -653,15 +683,28 @@ class ChatsModule {
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} else {
|
||||
final payloadRaw = m['payload'];
|
||||
if (payloadRaw is String && payloadRaw.isNotEmpty) {
|
||||
try {
|
||||
final payload = jsonDecode(payloadRaw);
|
||||
if (payload is Map) {
|
||||
final els = payload['elements'];
|
||||
if (els is List && els.isNotEmpty) elementsJson = jsonEncode(els);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
newRow['last_msg_id'] = int.tryParse(m['id']?.toString() ?? '');
|
||||
newRow['last_msg_text'] = previewText ?? m['text'];
|
||||
newRow['last_msg_elements'] = elementsJson;
|
||||
newRow['last_msg_time'] = m['time'];
|
||||
newRow['last_msg_sender'] = m['sender_id'];
|
||||
newRow['last_msg_status'] = m['status'];
|
||||
} else {
|
||||
newRow['last_msg_id'] = null;
|
||||
newRow['last_msg_text'] = lastMsgPlaceholder;
|
||||
newRow['last_msg_elements'] = null;
|
||||
newRow['last_msg_sender'] = null;
|
||||
newRow['last_msg_status'] = null;
|
||||
}
|
||||
@@ -937,6 +980,7 @@ class ChatsModule {
|
||||
if (a.lastMsgId != b.lastMsgId) return false;
|
||||
if (a.lastMsgTime != b.lastMsgTime) return false;
|
||||
if (a.lastMsgText != b.lastMsgText) return false;
|
||||
if (a.lastMsgElements != b.lastMsgElements) return false;
|
||||
if (a.lastMsgSenderId != b.lastMsgSenderId) return false;
|
||||
if (a.unreadCount != b.unreadCount) return false;
|
||||
if (a.lastEventTime != b.lastEventTime) return false;
|
||||
@@ -1100,12 +1144,14 @@ class ChatsModule {
|
||||
int? lastMsgId;
|
||||
int? lastMsgTime;
|
||||
String? lastMsgText;
|
||||
String? lastMsgElements;
|
||||
int? lastMsgSenderId;
|
||||
|
||||
if (lastMsg is Map) {
|
||||
lastMsgId = lastMsg['id'] as int?;
|
||||
lastMsgTime = lastMsg['time'] as int?;
|
||||
lastMsgText = messagePreviewText(lastMsg);
|
||||
lastMsgElements = messagePreviewElements(lastMsg);
|
||||
lastMsgSenderId = lastMsg['sender'] as int?;
|
||||
}
|
||||
|
||||
@@ -1169,6 +1215,7 @@ class ChatsModule {
|
||||
lastMsgId: lastMsgId,
|
||||
lastMsgTime: lastMsgTime,
|
||||
lastMsgText: lastMsgText,
|
||||
lastMsgElements: lastMsgElements,
|
||||
lastMsgSenderId: lastMsgSenderId,
|
||||
unreadCount: (chat['newMessages'] as int?) ?? 0,
|
||||
lastEventTime: (chat['lastEventTime'] as int?) ?? 0,
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../core/utils/text_format.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import 'chats.dart' show ChatsModule;
|
||||
|
||||
@@ -502,6 +503,8 @@ class CachedMessage {
|
||||
|
||||
ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload);
|
||||
|
||||
List<FormatRange> get formatRanges => parseFormatElements(payload?['elements']);
|
||||
|
||||
static List<CachedMessage> _decodeRows(List<Map<String, dynamic>> rows) =>
|
||||
rows.map(CachedMessage.fromDbRow).toList();
|
||||
|
||||
@@ -733,11 +736,12 @@ class MessagesModule {
|
||||
bool notify = true,
|
||||
int? scheduledTime,
|
||||
int? replyToMessageId,
|
||||
List<Map<String, dynamic>> elements = const [],
|
||||
}) async {
|
||||
final message = <String, dynamic>{
|
||||
'text': text,
|
||||
'cid': DateTime.now().millisecondsSinceEpoch * -1,
|
||||
'elements': [],
|
||||
'elements': elements,
|
||||
'attaches': [],
|
||||
};
|
||||
if (replyToMessageId != null) {
|
||||
|
||||
@@ -190,7 +190,7 @@ class AppDatabase {
|
||||
await _migrateLegacyDb(target);
|
||||
return openDatabase(
|
||||
target,
|
||||
version: 15,
|
||||
version: 16,
|
||||
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
|
||||
onCreate: (db, _) => _createTables(db),
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
@@ -247,6 +247,11 @@ class AppDatabase {
|
||||
if (oldVersion < 15) {
|
||||
await _addColumnIfMissing(db, 'messages', 'edit_history', 'TEXT');
|
||||
}
|
||||
if (oldVersion < 16) {
|
||||
await _addColumnIfMissing(
|
||||
db, 'chats_cache', 'last_msg_elements', 'TEXT',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -333,6 +338,7 @@ class AppDatabase {
|
||||
last_msg_id INTEGER,
|
||||
last_msg_time INTEGER,
|
||||
last_msg_text TEXT,
|
||||
last_msg_elements TEXT,
|
||||
last_msg_sender INTEGER,
|
||||
last_msg_status TEXT,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum TextFormat {
|
||||
strong,
|
||||
emphasized,
|
||||
underline,
|
||||
strikethrough,
|
||||
monospaced,
|
||||
quote,
|
||||
link,
|
||||
}
|
||||
|
||||
const Map<TextFormat, String> _formatToServer = {
|
||||
TextFormat.strong: 'STRONG',
|
||||
TextFormat.emphasized: 'EMPHASIZED',
|
||||
TextFormat.underline: 'UNDERLINE',
|
||||
TextFormat.strikethrough: 'STRIKETHROUGH',
|
||||
TextFormat.monospaced: 'MONOSPACED',
|
||||
TextFormat.quote: 'QUOTE',
|
||||
TextFormat.link: 'LINK',
|
||||
};
|
||||
|
||||
final Map<String, TextFormat> _serverToFormat = {
|
||||
for (final e in _formatToServer.entries) e.value: e.key,
|
||||
};
|
||||
|
||||
String textFormatToServer(TextFormat format) => _formatToServer[format]!;
|
||||
|
||||
TextFormat? textFormatFromServer(String? raw) =>
|
||||
raw == null ? null : _serverToFormat[raw];
|
||||
|
||||
class FormatRange {
|
||||
final TextFormat format;
|
||||
final int start;
|
||||
final int length;
|
||||
final Map<String, dynamic>? attributes;
|
||||
|
||||
const FormatRange({
|
||||
required this.format,
|
||||
required this.start,
|
||||
required this.length,
|
||||
this.attributes,
|
||||
});
|
||||
|
||||
int get end => start + length;
|
||||
|
||||
String? get url {
|
||||
final value = attributes?['url'];
|
||||
return value is String ? value : null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toServer() => {
|
||||
'type': textFormatToServer(format),
|
||||
'from': start,
|
||||
'length': length,
|
||||
if (attributes != null) 'attributes': attributes,
|
||||
};
|
||||
}
|
||||
|
||||
List<FormatRange> parseFormatElements(dynamic raw) {
|
||||
if (raw is! List) return const [];
|
||||
final result = <FormatRange>[];
|
||||
for (final item in raw) {
|
||||
if (item is! Map) continue;
|
||||
final format = textFormatFromServer(item['type']?.toString());
|
||||
if (format == null) continue;
|
||||
final from = _asInt(item['from']);
|
||||
final length = _asInt(item['length']);
|
||||
if (length <= 0) continue;
|
||||
final attrsRaw = item['attributes'];
|
||||
final attributes = attrsRaw is Map
|
||||
? Map<String, dynamic>.from(attrsRaw)
|
||||
: null;
|
||||
result.add(
|
||||
FormatRange(
|
||||
format: format,
|
||||
start: from,
|
||||
length: length,
|
||||
attributes: attributes,
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> serializeFormatElements(
|
||||
Iterable<FormatRange> ranges,
|
||||
) => [for (final range in ranges) range.toServer()];
|
||||
|
||||
int _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
class FormatSegment {
|
||||
final int start;
|
||||
final int end;
|
||||
final Set<TextFormat> formats;
|
||||
final String? url;
|
||||
|
||||
const FormatSegment({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.formats,
|
||||
this.url,
|
||||
});
|
||||
}
|
||||
|
||||
List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
|
||||
if (text.isEmpty) return const [];
|
||||
final length = text.length;
|
||||
final clamped = <FormatRange>[];
|
||||
for (final range in ranges) {
|
||||
final start = range.start.clamp(0, length);
|
||||
final end = range.end.clamp(0, length);
|
||||
if (end <= start) continue;
|
||||
clamped.add(
|
||||
FormatRange(
|
||||
format: range.format,
|
||||
start: start,
|
||||
length: end - start,
|
||||
attributes: range.attributes,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (clamped.isEmpty) {
|
||||
return [FormatSegment(start: 0, end: length, formats: const {})];
|
||||
}
|
||||
|
||||
final boundaries = <int>{0, length};
|
||||
for (final range in clamped) {
|
||||
boundaries.add(range.start);
|
||||
boundaries.add(range.end);
|
||||
}
|
||||
final points = boundaries.toList()..sort();
|
||||
|
||||
final segments = <FormatSegment>[];
|
||||
for (var i = 0; i < points.length - 1; i++) {
|
||||
final start = points[i];
|
||||
final end = points[i + 1];
|
||||
if (end <= start) continue;
|
||||
final formats = <TextFormat>{};
|
||||
String? url;
|
||||
for (final range in clamped) {
|
||||
if (range.start <= start && range.end >= end) {
|
||||
formats.add(range.format);
|
||||
if (range.format == TextFormat.link) url ??= range.url;
|
||||
}
|
||||
}
|
||||
segments.add(
|
||||
FormatSegment(start: start, end: end, formats: formats, url: url),
|
||||
);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
TextStyle applyTextFormats(
|
||||
TextStyle base,
|
||||
Set<TextFormat> formats, {
|
||||
Color? linkColor,
|
||||
Color? quoteColor,
|
||||
Color? monoColor,
|
||||
}) {
|
||||
if (formats.isEmpty) return base;
|
||||
var style = base;
|
||||
final decorations = <TextDecoration>[];
|
||||
|
||||
if (formats.contains(TextFormat.strong)) {
|
||||
style = style.merge(const TextStyle(fontWeight: FontWeight.w700));
|
||||
}
|
||||
if (formats.contains(TextFormat.emphasized) ||
|
||||
formats.contains(TextFormat.quote)) {
|
||||
style = style.merge(const TextStyle(fontStyle: FontStyle.italic));
|
||||
}
|
||||
if (formats.contains(TextFormat.monospaced)) {
|
||||
style = style.merge(
|
||||
TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
color: monoColor ?? style.color,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (formats.contains(TextFormat.quote) && quoteColor != null) {
|
||||
style = style.merge(TextStyle(color: quoteColor));
|
||||
}
|
||||
if (formats.contains(TextFormat.underline)) {
|
||||
decorations.add(TextDecoration.underline);
|
||||
}
|
||||
if (formats.contains(TextFormat.strikethrough)) {
|
||||
decorations.add(TextDecoration.lineThrough);
|
||||
}
|
||||
if (formats.contains(TextFormat.link)) {
|
||||
decorations.add(TextDecoration.underline);
|
||||
if (linkColor != null) style = style.merge(TextStyle(color: linkColor));
|
||||
}
|
||||
|
||||
if (decorations.isNotEmpty) {
|
||||
style = style.merge(
|
||||
TextStyle(decoration: TextDecoration.combine(decorations)),
|
||||
);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
@@ -17,7 +17,9 @@ import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/sheet_helpers.dart';
|
||||
import '../../widgets/swipe_route.dart';
|
||||
import '../../widgets/sliding_pill_nav.dart';
|
||||
import '../../widgets/formatted_message_text.dart';
|
||||
import '../../../core/utils/format.dart';
|
||||
import '../../../core/utils/text_format.dart';
|
||||
|
||||
import '../calls/calls_tab.dart';
|
||||
import '../contacts/contacts_tab.dart';
|
||||
@@ -1540,6 +1542,9 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
draft: _draftFor(chat.id),
|
||||
ownStatus: _ownStatusFor(chat, isPlaceholder),
|
||||
ownRead: chat.lastMsgReadByOthers,
|
||||
messageRanges: isPlaceholder
|
||||
? const []
|
||||
: chat.lastMsgFormatRanges,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -1550,14 +1555,30 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
: null;
|
||||
|
||||
String fullMsg = "";
|
||||
List<FormatRange> messageRanges = const [];
|
||||
if (isPlaceholder) {
|
||||
fullMsg = 'зайдите в чат для подгрузки';
|
||||
} else {
|
||||
var prefixLen = 0;
|
||||
if (sender?.isNotEmpty == true && chat.id != 0) {
|
||||
fullMsg += "$sender: ";
|
||||
final prefix = "$sender: ";
|
||||
fullMsg += prefix;
|
||||
prefixLen = prefix.length;
|
||||
}
|
||||
if (chat.lastMsgText?.isNotEmpty == true) {
|
||||
fullMsg += chat.lastMsgText ?? "";
|
||||
final ranges = chat.lastMsgFormatRanges;
|
||||
messageRanges = prefixLen == 0
|
||||
? ranges
|
||||
: [
|
||||
for (final r in ranges)
|
||||
FormatRange(
|
||||
format: r.format,
|
||||
start: r.start + prefixLen,
|
||||
length: r.length,
|
||||
attributes: r.attributes,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1580,6 +1601,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
draft: chat.id == 0 ? null : _draftFor(chat.id),
|
||||
ownStatus: _ownStatusFor(chat, isPlaceholder),
|
||||
ownRead: chat.lastMsgReadByOthers,
|
||||
messageRanges: messageRanges,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2216,6 +2238,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
String? draft,
|
||||
String? ownStatus,
|
||||
bool ownRead = false,
|
||||
List<FormatRange> messageRanges = const [],
|
||||
}) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isSelected = _selectedChats.contains(id);
|
||||
@@ -2427,19 +2450,35 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: cs.outline,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontStyle: messageItalic
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
: Builder(
|
||||
builder: (_) {
|
||||
final previewStyle = TextStyle(
|
||||
color: cs.outline,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontStyle: messageItalic
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
height: 1.2,
|
||||
);
|
||||
if (messageRanges.isEmpty) {
|
||||
return Text(
|
||||
message,
|
||||
style: previewStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
return Text.rich(
|
||||
FormattedMessageText.buildInlineSpan(
|
||||
message,
|
||||
messageRanges,
|
||||
previewStyle,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -53,6 +53,8 @@ import '../../../models/sticker.dart';
|
||||
import '../../commands/command_registry.dart';
|
||||
import '../../commands/slash_command.dart';
|
||||
import '../../widgets/glossy_pill.dart';
|
||||
import '../../widgets/rich_message_controller.dart';
|
||||
import '../../../core/utils/text_format.dart';
|
||||
import '../../widgets/command_suggestions_panel.dart';
|
||||
import '../../widgets/online_dot.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
@@ -201,7 +203,7 @@ class ChatScreen extends StatefulWidget {
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen>
|
||||
with TickerProviderStateMixin, WidgetsBindingObserver {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final RichMessageController _messageController = RichMessageController();
|
||||
final FocusNode _messageFocusNode = FocusNode();
|
||||
double _keyboardReserve = 0;
|
||||
bool _keyboardWasOpen = false;
|
||||
@@ -1531,7 +1533,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
|
||||
Future<void> _startEditMessage(CachedMessage message) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final controller = TextEditingController(text: message.text ?? '');
|
||||
final controller = RichMessageController(text: message.text ?? '')
|
||||
..setFormatRanges(message.formatRanges);
|
||||
|
||||
final saved = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
@@ -1567,6 +1570,8 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
minLines: 1,
|
||||
maxLines: 6,
|
||||
style: TextStyle(color: cs.onSurface),
|
||||
contextMenuBuilder: (ctx, state) =>
|
||||
_formatContextMenu(controller, ctx, state),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Текст сообщения',
|
||||
filled: true,
|
||||
@@ -1592,14 +1597,24 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
final newText = controller.text.trim();
|
||||
final rawText = controller.text;
|
||||
final newText = rawText.trim();
|
||||
final elements = _trimmedElements(controller, rawText, newText);
|
||||
controller.dispose();
|
||||
if (newText == (message.text ?? '')) return;
|
||||
|
||||
final oldElements = serializeFormatElements(
|
||||
message.formatRanges.where((r) => composerFormats.contains(r.format)),
|
||||
);
|
||||
if (newText == (message.text ?? '') &&
|
||||
_sameElements(elements, oldElements)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final ok = await messagesModule.editMessage(
|
||||
widget.chatId,
|
||||
message.id,
|
||||
text: newText,
|
||||
elements: elements,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!ok) {
|
||||
@@ -1626,7 +1641,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
text: newText.isEmpty ? null : newText,
|
||||
time: old.time,
|
||||
status: 'EDITED',
|
||||
payload: old.payload,
|
||||
payload: {...?old.payload, 'elements': elements},
|
||||
attachments: old.attachments,
|
||||
isControl: old.isControl,
|
||||
editHistory: newHistory,
|
||||
@@ -2634,8 +2649,98 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
_syncOtherReadTime();
|
||||
}
|
||||
|
||||
static String _formatLabel(TextFormat format) {
|
||||
switch (format) {
|
||||
case TextFormat.strong:
|
||||
return 'Жирный';
|
||||
case TextFormat.emphasized:
|
||||
return 'Курсив';
|
||||
case TextFormat.underline:
|
||||
return 'Подчёркнутый';
|
||||
case TextFormat.strikethrough:
|
||||
return 'Зачёркнутый';
|
||||
case TextFormat.monospaced:
|
||||
return 'Моноширинный';
|
||||
case TextFormat.quote:
|
||||
return 'Цитата';
|
||||
case TextFormat.link:
|
||||
return 'Ссылка';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _formatContextMenu(
|
||||
RichMessageController controller,
|
||||
BuildContext context,
|
||||
EditableTextState editableState,
|
||||
) {
|
||||
final selection = controller.selection;
|
||||
final buttonItems = <ContextMenuButtonItem>[];
|
||||
if (selection.isValid && !selection.isCollapsed) {
|
||||
for (final format in composerFormats) {
|
||||
final active = controller.isFormatActive(format);
|
||||
buttonItems.add(
|
||||
ContextMenuButtonItem(
|
||||
label: '${active ? '✓ ' : ''}${_formatLabel(format)}',
|
||||
onPressed: () {
|
||||
controller.toggleFormat(format);
|
||||
editableState.hideToolbar();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
buttonItems.addAll(editableState.contextMenuButtonItems);
|
||||
return AdaptiveTextSelectionToolbar.buttonItems(
|
||||
anchors: editableState.contextMenuAnchors,
|
||||
buttonItems: buttonItems,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _sameElements(
|
||||
List<Map<String, dynamic>> a,
|
||||
List<Map<String, dynamic>> b,
|
||||
) {
|
||||
if (a.length != b.length) return false;
|
||||
String canon(List<Map<String, dynamic>> els) {
|
||||
final copy = [...els]..sort((x, y) {
|
||||
final t = (x['type'] as String).compareTo(y['type'] as String);
|
||||
return t != 0 ? t : (x['from'] as int).compareTo(y['from'] as int);
|
||||
});
|
||||
return copy
|
||||
.map((e) => '${e['type']}:${e['from']}:${e['length']}')
|
||||
.join(',');
|
||||
}
|
||||
|
||||
return canon(a) == canon(b);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _trimmedElements(
|
||||
RichMessageController controller,
|
||||
String rawText,
|
||||
String text,
|
||||
) {
|
||||
final raw = controller.elementsForSend();
|
||||
if (raw.isEmpty) return const [];
|
||||
final leading = rawText.length - rawText.trimLeft().length;
|
||||
final result = <Map<String, dynamic>>[];
|
||||
for (final element in raw) {
|
||||
var from = (element['from'] as int) - leading;
|
||||
var length = element['length'] as int;
|
||||
if (from < 0) {
|
||||
length += from;
|
||||
from = 0;
|
||||
}
|
||||
if (from >= text.length || length <= 0) continue;
|
||||
if (from + length > text.length) length = text.length - from;
|
||||
if (length <= 0) continue;
|
||||
result.add({...element, 'from': from, 'length': length});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _messageController.text.trim();
|
||||
final rawText = _messageController.text;
|
||||
final text = rawText.trim();
|
||||
if (text.isEmpty || _myId == 0) return;
|
||||
|
||||
if (AppCommands.current.value && text.startsWith('/')) {
|
||||
@@ -2679,6 +2784,15 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
}
|
||||
_replyTo.value = null;
|
||||
|
||||
final elements = _trimmedElements(_messageController, rawText, text);
|
||||
final Map<String, dynamic>? composedPayload =
|
||||
(replyPayload == null && elements.isEmpty)
|
||||
? null
|
||||
: {
|
||||
...?replyPayload,
|
||||
if (elements.isNotEmpty) 'elements': elements,
|
||||
};
|
||||
|
||||
final composed = CachedMessage(
|
||||
id: tempId,
|
||||
accountId: _myId,
|
||||
@@ -2687,7 +2801,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
text: text,
|
||||
time: now,
|
||||
status: online ? 'sending' : 'pending',
|
||||
payload: replyPayload,
|
||||
payload: composedPayload,
|
||||
);
|
||||
|
||||
_hasText.value = false;
|
||||
@@ -2706,6 +2820,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
time: now,
|
||||
text: text,
|
||||
status: composed.status ?? 'sending',
|
||||
elements: elements,
|
||||
));
|
||||
|
||||
// Instant tactile "whoosh" the moment the message leaves the composer,
|
||||
@@ -2723,6 +2838,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
widget.chatId,
|
||||
text,
|
||||
replyToMessageId: replyId,
|
||||
elements: elements,
|
||||
);
|
||||
|
||||
final index = _messages.indexWhere((m) => m.id == tempId);
|
||||
@@ -2735,7 +2851,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
text: text,
|
||||
time: now,
|
||||
status: 'sent',
|
||||
payload: replyPayload,
|
||||
payload: composedPayload,
|
||||
);
|
||||
_messages[index] = sent;
|
||||
_bumpMessages();
|
||||
@@ -2747,6 +2863,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
time: now,
|
||||
text: text,
|
||||
status: 'sent',
|
||||
elements: elements,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2770,7 +2887,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
text: text,
|
||||
time: now,
|
||||
status: 'pending',
|
||||
payload: replyPayload,
|
||||
payload: composedPayload,
|
||||
);
|
||||
_messages[index] = queued;
|
||||
_bumpMessages();
|
||||
@@ -2782,6 +2899,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
time: now,
|
||||
text: text,
|
||||
status: 'pending',
|
||||
elements: elements,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -4588,6 +4706,12 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
contextMenuBuilder: (ctx, state) =>
|
||||
_formatContextMenu(
|
||||
_messageController,
|
||||
ctx,
|
||||
state,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message',
|
||||
hintStyle: TextStyle(
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/link_opener.dart';
|
||||
import '../../core/utils/text_format.dart';
|
||||
import 'link_text.dart';
|
||||
|
||||
class FormattedMessageText extends StatefulWidget {
|
||||
final String text;
|
||||
final List<FormatRange> ranges;
|
||||
final TextStyle style;
|
||||
final TextAlign textAlign;
|
||||
|
||||
const FormattedMessageText({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.ranges,
|
||||
required this.style,
|
||||
this.textAlign = TextAlign.start,
|
||||
});
|
||||
|
||||
static bool isFormatted(String? text, List<FormatRange> ranges) =>
|
||||
text != null &&
|
||||
text.isNotEmpty &&
|
||||
(ranges.isNotEmpty || LinkText.hasLinks(text));
|
||||
|
||||
static TextSpan buildInlineSpan(
|
||||
String text,
|
||||
List<FormatRange> ranges,
|
||||
TextStyle style,
|
||||
) {
|
||||
final quoteColor = style.color?.withValues(alpha: 0.85);
|
||||
final segments = segmentizeFormats(text, ranges);
|
||||
return TextSpan(
|
||||
style: style,
|
||||
children: [
|
||||
for (final segment in segments)
|
||||
TextSpan(
|
||||
text: text.substring(segment.start, segment.end),
|
||||
style: applyTextFormats(
|
||||
style,
|
||||
segment.formats,
|
||||
quoteColor: quoteColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<FormattedMessageText> createState() => _FormattedMessageTextState();
|
||||
}
|
||||
|
||||
class _FormattedMessageTextState extends State<FormattedMessageText> {
|
||||
final List<TapGestureRecognizer> _recognizers = [];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeRecognizers() {
|
||||
for (final recognizer in _recognizers) {
|
||||
recognizer.dispose();
|
||||
}
|
||||
_recognizers.clear();
|
||||
}
|
||||
|
||||
List<FormatRange> _withAutoLinks() {
|
||||
final ranges = List<FormatRange>.from(widget.ranges);
|
||||
final hasExplicitLink = ranges.any((r) => r.format == TextFormat.link);
|
||||
if (hasExplicitLink) return ranges;
|
||||
for (final match in linkPattern.allMatches(widget.text)) {
|
||||
final raw = match.group(0)!;
|
||||
final target = raw.startsWith('www.') ? 'https://$raw' : raw;
|
||||
ranges.add(
|
||||
FormatRange(
|
||||
format: TextFormat.link,
|
||||
start: match.start,
|
||||
length: match.end - match.start,
|
||||
attributes: {'url': target},
|
||||
),
|
||||
);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_disposeRecognizers();
|
||||
final segments = segmentizeFormats(widget.text, _withAutoLinks());
|
||||
final baseColor = widget.style.color ?? Theme.of(context).colorScheme.onSurface;
|
||||
final barColor = baseColor.withValues(alpha: 0.4);
|
||||
final quoteColor = baseColor.withValues(alpha: 0.85);
|
||||
|
||||
final spans = <InlineSpan>[];
|
||||
var prevQuote = false;
|
||||
for (final segment in segments) {
|
||||
final isQuote = segment.formats.contains(TextFormat.quote);
|
||||
if (isQuote && !prevQuote) {
|
||||
spans.add(
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Container(
|
||||
width: 3,
|
||||
height: (widget.style.fontSize ?? 16) * 1.15,
|
||||
margin: const EdgeInsets.only(right: 6, left: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: barColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
prevQuote = isQuote;
|
||||
|
||||
final style = applyTextFormats(
|
||||
widget.style,
|
||||
segment.formats,
|
||||
quoteColor: quoteColor,
|
||||
);
|
||||
final content = widget.text.substring(segment.start, segment.end);
|
||||
if (segment.url != null) {
|
||||
final url = segment.url!;
|
||||
final recognizer = TapGestureRecognizer()
|
||||
..onTap = () => openExternalUrl(context, url);
|
||||
_recognizers.add(recognizer);
|
||||
spans.add(
|
||||
TextSpan(text: content, style: style, recognizer: recognizer),
|
||||
);
|
||||
} else {
|
||||
spans.add(TextSpan(text: content, style: style));
|
||||
}
|
||||
}
|
||||
|
||||
return Text.rich(
|
||||
TextSpan(style: widget.style, children: spans),
|
||||
textAlign: widget.textAlign,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/link_opener.dart';
|
||||
|
||||
final RegExp _urlPattern = RegExp(
|
||||
final RegExp linkPattern = RegExp(
|
||||
r'(https?://[^\s<>]+|www\.[^\s<>]+)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
@@ -15,7 +15,7 @@ class LinkText extends StatefulWidget {
|
||||
const LinkText({super.key, required this.text, required this.style});
|
||||
|
||||
static bool hasLinks(String? text) =>
|
||||
text != null && _urlPattern.hasMatch(text);
|
||||
text != null && linkPattern.hasMatch(text);
|
||||
|
||||
@override
|
||||
State<LinkText> createState() => _LinkTextState();
|
||||
@@ -41,7 +41,7 @@ class _LinkTextState extends State<LinkText> {
|
||||
|
||||
final spans = <InlineSpan>[];
|
||||
var cursor = 0;
|
||||
for (final match in _urlPattern.allMatches(widget.text)) {
|
||||
for (final match in linkPattern.allMatches(widget.text)) {
|
||||
if (match.start > cursor) {
|
||||
spans.add(TextSpan(text: widget.text.substring(cursor, match.start)));
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import '../../core/utils/link_opener.dart';
|
||||
import '../../core/utils/webview_support.dart';
|
||||
import '../../core/config/app_link_preview.dart';
|
||||
import 'custom_notification.dart';
|
||||
import 'link_text.dart';
|
||||
import 'formatted_message_text.dart';
|
||||
import 'sticker_image.dart';
|
||||
import '../../models/attachment.dart';
|
||||
import 'poll_view.dart';
|
||||
@@ -935,10 +935,15 @@ class MessageBubble extends StatelessWidget {
|
||||
final hasReactions = reactionChips.isNotEmpty;
|
||||
|
||||
final textStyle = TextStyle(color: ctx.text, fontSize: 16, height: 1.3);
|
||||
final ranges = message.formatRanges;
|
||||
final textWidget = isForwarded
|
||||
? _buildForwardedInlineText(ctx, forwarded)
|
||||
: (LinkText.hasLinks(message.text)
|
||||
? LinkText(text: message.text!, style: textStyle)
|
||||
: (FormattedMessageText.isFormatted(message.text, ranges)
|
||||
? FormattedMessageText(
|
||||
text: message.text!,
|
||||
ranges: ranges,
|
||||
style: textStyle,
|
||||
)
|
||||
: Text(message.text ?? '', style: textStyle));
|
||||
|
||||
final metaWidget = Text(
|
||||
@@ -1385,8 +1390,9 @@ class MessageBubble extends StatelessWidget {
|
||||
if (hasText) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: LinkText(
|
||||
child: FormattedMessageText(
|
||||
text: message.text!,
|
||||
ranges: message.formatRanges,
|
||||
style: TextStyle(
|
||||
color: ctx.text,
|
||||
fontSize: 16,
|
||||
@@ -1822,8 +1828,13 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
Widget _buildCaption(_BubbleCtx ctx) {
|
||||
final style = TextStyle(color: ctx.text, fontSize: 16, height: 1.3);
|
||||
if (LinkText.hasLinks(message.text)) {
|
||||
return LinkText(text: message.text!, style: style);
|
||||
final ranges = message.formatRanges;
|
||||
if (FormattedMessageText.isFormatted(message.text, ranges)) {
|
||||
return FormattedMessageText(
|
||||
text: message.text!,
|
||||
ranges: ranges,
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
return Text(message.text ?? '', style: style);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/text_format.dart';
|
||||
|
||||
const List<TextFormat> composerFormats = [
|
||||
TextFormat.strong,
|
||||
TextFormat.emphasized,
|
||||
TextFormat.underline,
|
||||
TextFormat.strikethrough,
|
||||
TextFormat.quote,
|
||||
];
|
||||
|
||||
class _Interval {
|
||||
int start;
|
||||
int end;
|
||||
_Interval(this.start, this.end);
|
||||
}
|
||||
|
||||
class RichMessageController extends TextEditingController {
|
||||
final Map<TextFormat, List<_Interval>> _intervals = {};
|
||||
|
||||
RichMessageController({super.text});
|
||||
|
||||
@override
|
||||
set value(TextEditingValue newValue) {
|
||||
final oldText = value.text;
|
||||
final newText = newValue.text;
|
||||
if (oldText != newText) {
|
||||
_remap(oldText, newText);
|
||||
}
|
||||
super.value = newValue;
|
||||
}
|
||||
|
||||
bool get hasFormatting =>
|
||||
_intervals.values.any((list) => list.isNotEmpty);
|
||||
|
||||
void clearFormatting() {
|
||||
if (_intervals.isEmpty) return;
|
||||
_intervals.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setFormatRanges(Iterable<FormatRange> ranges) {
|
||||
_intervals.clear();
|
||||
for (final range in ranges) {
|
||||
if (!composerFormats.contains(range.format)) continue;
|
||||
_intervals.putIfAbsent(range.format, () => []).add(
|
||||
_Interval(range.start, range.end),
|
||||
);
|
||||
}
|
||||
for (final list in _intervals.values) {
|
||||
_normalize(list);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> elementsForSend() {
|
||||
final ranges = <FormatRange>[];
|
||||
_intervals.forEach((format, list) {
|
||||
for (final interval in list) {
|
||||
ranges.add(
|
||||
FormatRange(
|
||||
format: format,
|
||||
start: interval.start,
|
||||
length: interval.end - interval.start,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
return serializeFormatElements(ranges);
|
||||
}
|
||||
|
||||
bool isFormatActive(TextFormat format) {
|
||||
final selection = value.selection;
|
||||
if (!selection.isValid || selection.isCollapsed) return false;
|
||||
return _isCovered(_intervals[format], selection.start, selection.end);
|
||||
}
|
||||
|
||||
void toggleFormat(TextFormat format) {
|
||||
final selection = value.selection;
|
||||
if (!selection.isValid || selection.isCollapsed) return;
|
||||
final start = selection.start;
|
||||
final end = selection.end;
|
||||
final list = _intervals.putIfAbsent(format, () => []);
|
||||
if (_isCovered(list, start, end)) {
|
||||
_subtract(list, start, end);
|
||||
} else {
|
||||
_add(list, start, end);
|
||||
}
|
||||
if (list.isEmpty) _intervals.remove(format);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _remap(String oldText, String newText) {
|
||||
if (_intervals.isEmpty) return;
|
||||
final oldLen = oldText.length;
|
||||
final newLen = newText.length;
|
||||
|
||||
var prefix = 0;
|
||||
final maxPrefix = oldLen < newLen ? oldLen : newLen;
|
||||
while (prefix < maxPrefix && oldText[prefix] == newText[prefix]) {
|
||||
prefix++;
|
||||
}
|
||||
var suffix = 0;
|
||||
while (suffix < maxPrefix - prefix &&
|
||||
oldText[oldLen - 1 - suffix] == newText[newLen - 1 - suffix]) {
|
||||
suffix++;
|
||||
}
|
||||
|
||||
final changeStart = prefix;
|
||||
final oldChangeEnd = oldLen - suffix;
|
||||
final delta = newLen - oldLen;
|
||||
|
||||
int mapStart(int offset) {
|
||||
if (offset < changeStart) return offset;
|
||||
if (offset >= oldChangeEnd) return offset + delta;
|
||||
return changeStart;
|
||||
}
|
||||
|
||||
int mapEnd(int offset) {
|
||||
if (offset <= changeStart) return offset;
|
||||
if (offset >= oldChangeEnd) return offset + delta;
|
||||
return changeStart;
|
||||
}
|
||||
|
||||
final empty = <TextFormat>[];
|
||||
_intervals.forEach((format, list) {
|
||||
for (final interval in list) {
|
||||
interval.start = mapStart(interval.start);
|
||||
interval.end = mapEnd(interval.end);
|
||||
}
|
||||
list.removeWhere((interval) => interval.end <= interval.start);
|
||||
_normalize(list);
|
||||
if (list.isEmpty) empty.add(format);
|
||||
});
|
||||
for (final format in empty) {
|
||||
_intervals.remove(format);
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isCovered(List<_Interval>? list, int start, int end) {
|
||||
if (list == null || list.isEmpty) return false;
|
||||
var cursor = start;
|
||||
final sorted = [...list]..sort((a, b) => a.start.compareTo(b.start));
|
||||
for (final interval in sorted) {
|
||||
if (interval.start > cursor) return false;
|
||||
if (interval.end > cursor) cursor = interval.end;
|
||||
if (cursor >= end) return true;
|
||||
}
|
||||
return cursor >= end;
|
||||
}
|
||||
|
||||
static void _add(List<_Interval> list, int start, int end) {
|
||||
list.add(_Interval(start, end));
|
||||
_normalize(list);
|
||||
}
|
||||
|
||||
static void _subtract(List<_Interval> list, int start, int end) {
|
||||
final result = <_Interval>[];
|
||||
for (final interval in list) {
|
||||
if (interval.end <= start || interval.start >= end) {
|
||||
result.add(interval);
|
||||
continue;
|
||||
}
|
||||
if (interval.start < start) {
|
||||
result.add(_Interval(interval.start, start));
|
||||
}
|
||||
if (interval.end > end) {
|
||||
result.add(_Interval(end, interval.end));
|
||||
}
|
||||
}
|
||||
list
|
||||
..clear()
|
||||
..addAll(result);
|
||||
_normalize(list);
|
||||
}
|
||||
|
||||
static void _normalize(List<_Interval> list) {
|
||||
if (list.length < 2) return;
|
||||
list.sort((a, b) => a.start.compareTo(b.start));
|
||||
final merged = <_Interval>[list.first];
|
||||
for (var i = 1; i < list.length; i++) {
|
||||
final current = list[i];
|
||||
final last = merged.last;
|
||||
if (current.start <= last.end) {
|
||||
if (current.end > last.end) last.end = current.end;
|
||||
} else {
|
||||
merged.add(current);
|
||||
}
|
||||
}
|
||||
list
|
||||
..clear()
|
||||
..addAll(merged);
|
||||
}
|
||||
|
||||
@override
|
||||
TextSpan buildTextSpan({
|
||||
required BuildContext context,
|
||||
TextStyle? style,
|
||||
required bool withComposing,
|
||||
}) {
|
||||
final baseStyle = style ?? const TextStyle();
|
||||
final content = text;
|
||||
if (!hasFormatting || content.isEmpty) {
|
||||
return TextSpan(style: baseStyle, text: content);
|
||||
}
|
||||
|
||||
final ranges = <FormatRange>[];
|
||||
_intervals.forEach((format, list) {
|
||||
for (final interval in list) {
|
||||
ranges.add(
|
||||
FormatRange(
|
||||
format: format,
|
||||
start: interval.start,
|
||||
length: interval.end - interval.start,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
final baseColor = baseStyle.color;
|
||||
final quoteColor = baseColor?.withValues(alpha: 0.85);
|
||||
final segments = segmentizeFormats(content, ranges);
|
||||
final spans = <InlineSpan>[
|
||||
for (final segment in segments)
|
||||
TextSpan(
|
||||
text: content.substring(segment.start, segment.end),
|
||||
style: applyTextFormats(
|
||||
baseStyle,
|
||||
segment.formats,
|
||||
quoteColor: quoteColor,
|
||||
),
|
||||
),
|
||||
];
|
||||
return TextSpan(style: baseStyle, children: spans);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user