452 lines
12 KiB
Dart
452 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../../core/utils/text_format.dart';
|
||
import '../../models/animoji.dart';
|
||
import 'formatted_message_text.dart';
|
||
import 'lottie_image.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 _MentionEntity {
|
||
int start;
|
||
int end;
|
||
final int userId;
|
||
_MentionEntity(this.start, this.end, this.userId);
|
||
}
|
||
|
||
class _AnimojiEntity {
|
||
final int uid;
|
||
int offset;
|
||
final String emoji;
|
||
final String lottieUrl;
|
||
final int entityId;
|
||
|
||
_AnimojiEntity({
|
||
required this.uid,
|
||
required this.offset,
|
||
required this.emoji,
|
||
required this.lottieUrl,
|
||
required this.entityId,
|
||
});
|
||
}
|
||
|
||
class RichMessageController extends TextEditingController {
|
||
static const String _animojiPlaceholder = '';
|
||
|
||
final Map<TextFormat, List<_Interval>> _intervals = {};
|
||
final List<_AnimojiEntity> _animoji = [];
|
||
final List<_MentionEntity> _mentions = [];
|
||
int _entitySeq = 0;
|
||
|
||
RichMessageController({super.text});
|
||
|
||
void insertAnimoji(Animoji animoji) {
|
||
final lottie = animoji.lottieUrl ?? animoji.lottiePlayUrl;
|
||
if (lottie == null || lottie.isEmpty) return;
|
||
|
||
final selection = value.selection;
|
||
final oldText = value.text;
|
||
final start = selection.isValid ? selection.start : oldText.length;
|
||
final end = selection.isValid ? selection.end : oldText.length;
|
||
final newText = oldText.replaceRange(start, end, _animojiPlaceholder);
|
||
|
||
value = TextEditingValue(
|
||
text: newText,
|
||
selection: TextSelection.collapsed(
|
||
offset: start + _animojiPlaceholder.length,
|
||
),
|
||
);
|
||
|
||
_animoji.add(
|
||
_AnimojiEntity(
|
||
uid: _entitySeq++,
|
||
offset: start,
|
||
emoji: animoji.emoji,
|
||
lottieUrl: lottie,
|
||
entityId: animoji.id,
|
||
),
|
||
);
|
||
_animoji.sort((a, b) => a.offset.compareTo(b.offset));
|
||
notifyListeners();
|
||
}
|
||
|
||
void insertMention({
|
||
required int userId,
|
||
required String name,
|
||
required int start,
|
||
required int end,
|
||
}) {
|
||
final oldText = value.text;
|
||
if (start < 0 || end > oldText.length || start > end || name.isEmpty) {
|
||
return;
|
||
}
|
||
final inserted = '$name ';
|
||
value = TextEditingValue(
|
||
text: oldText.replaceRange(start, end, inserted),
|
||
selection: TextSelection.collapsed(offset: start + inserted.length),
|
||
);
|
||
|
||
_mentions.add(_MentionEntity(start, start + name.length, userId));
|
||
_mentions.sort((a, b) => a.start.compareTo(b.start));
|
||
notifyListeners();
|
||
}
|
||
|
||
({String text, List<Map<String, dynamic>> elements}) buildContent() {
|
||
final src = value.text;
|
||
if (_animoji.isEmpty) {
|
||
return (text: src, elements: elementsForSend());
|
||
}
|
||
|
||
final entities = [..._animoji]
|
||
..sort((a, b) => a.offset.compareTo(b.offset));
|
||
|
||
final sb = StringBuffer();
|
||
var last = 0;
|
||
for (final e in entities) {
|
||
if (e.offset < last || e.offset >= src.length) continue;
|
||
sb.write(src.substring(last, e.offset));
|
||
sb.write(e.emoji);
|
||
last = e.offset + _animojiPlaceholder.length;
|
||
}
|
||
sb.write(src.substring(last));
|
||
final glyphText = sb.toString();
|
||
|
||
int glyphOffset(int p) {
|
||
var shift = 0;
|
||
for (final e in entities) {
|
||
if (e.offset < p && e.offset < src.length) {
|
||
shift += e.emoji.length - _animojiPlaceholder.length;
|
||
}
|
||
}
|
||
return p + shift;
|
||
}
|
||
|
||
final elements = <Map<String, dynamic>>[];
|
||
for (final e in entities) {
|
||
if (e.offset >= src.length) continue;
|
||
elements.add({
|
||
'type': 'ANIMOJI',
|
||
'from': glyphOffset(e.offset),
|
||
'length': e.emoji.length,
|
||
'entityId': e.entityId,
|
||
'attributes': {'animojiLottieUrl': e.lottieUrl},
|
||
});
|
||
}
|
||
for (final range in _toFormatRanges()) {
|
||
final from = glyphOffset(range.start);
|
||
final to = glyphOffset(range.end);
|
||
if (to <= from) continue;
|
||
elements.add({
|
||
'type': textFormatToServer(range.format),
|
||
'from': from,
|
||
'length': to - from,
|
||
if (range.entityId != null) 'entityId': range.entityId,
|
||
});
|
||
}
|
||
return (text: glyphText, elements: elements);
|
||
}
|
||
|
||
@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) || _mentions.isNotEmpty;
|
||
|
||
void clearFormatting() {
|
||
if (_intervals.isEmpty) return;
|
||
_intervals.clear();
|
||
notifyListeners();
|
||
}
|
||
|
||
void setFormatRanges(Iterable<FormatRange> ranges) {
|
||
_intervals.clear();
|
||
_mentions.clear();
|
||
for (final range in ranges) {
|
||
if (range.format == TextFormat.userMention) {
|
||
final userId = range.entityId;
|
||
if (userId != null) {
|
||
_mentions.add(_MentionEntity(range.start, range.end, userId));
|
||
}
|
||
continue;
|
||
}
|
||
if (!composerFormats.contains(range.format)) continue;
|
||
_intervals
|
||
.putIfAbsent(range.format, () => [])
|
||
.add(_Interval(range.start, range.end));
|
||
}
|
||
_mentions.sort((a, b) => a.start.compareTo(b.start));
|
||
for (final list in _intervals.values) {
|
||
_normalize(list);
|
||
}
|
||
notifyListeners();
|
||
}
|
||
|
||
List<Map<String, dynamic>> elementsForSend() {
|
||
return serializeFormatElements(_toFormatRanges());
|
||
}
|
||
|
||
List<FormatRange> _toFormatRanges() {
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
});
|
||
for (final mention in _mentions) {
|
||
ranges.add(
|
||
FormatRange(
|
||
format: TextFormat.userMention,
|
||
start: mention.start,
|
||
length: mention.end - mention.start,
|
||
entityId: mention.userId,
|
||
),
|
||
);
|
||
}
|
||
return 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 && _animoji.isEmpty && _mentions.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;
|
||
}
|
||
|
||
if (_animoji.isNotEmpty) {
|
||
_animoji.removeWhere(
|
||
(e) => e.offset >= changeStart && e.offset < oldChangeEnd,
|
||
);
|
||
for (final e in _animoji) {
|
||
if (e.offset >= oldChangeEnd) e.offset += delta;
|
||
}
|
||
}
|
||
|
||
if (_mentions.isNotEmpty) {
|
||
_mentions.removeWhere(
|
||
(mention) => changeStart < mention.end && oldChangeEnd > mention.start,
|
||
);
|
||
for (final mention in _mentions) {
|
||
mention.start = mapStart(mention.start);
|
||
mention.end = mapEnd(mention.end);
|
||
}
|
||
}
|
||
|
||
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 && _animoji.isEmpty) || content.isEmpty) {
|
||
return TextSpan(style: baseStyle, text: content);
|
||
}
|
||
|
||
final ranges = _toFormatRanges();
|
||
final baseColor = baseStyle.color;
|
||
final quoteColor = baseColor?.withValues(alpha: 0.85);
|
||
final quoteBackground = baseColor == null
|
||
? null
|
||
: (Paint()..color = baseColor.withValues(alpha: 0.12));
|
||
final mentionColor = mentionTextColor(Theme.of(context).colorScheme);
|
||
final segments = segmentizeFormats(content, ranges);
|
||
final entityByOffset = {for (final e in _animoji) e.offset: e};
|
||
final box = (baseStyle.fontSize ?? 16) * 1.4;
|
||
|
||
final spans = <InlineSpan>[];
|
||
for (final segment in segments) {
|
||
final segStyle = applyTextFormats(
|
||
baseStyle,
|
||
segment.formats,
|
||
quoteColor: quoteColor,
|
||
mentionColor: mentionColor,
|
||
quoteBackground: quoteBackground,
|
||
);
|
||
var runStart = segment.start;
|
||
var i = segment.start;
|
||
while (i < segment.end) {
|
||
final entity = entityByOffset[i];
|
||
if (entity == null) {
|
||
i++;
|
||
continue;
|
||
}
|
||
if (runStart < i) {
|
||
spans.add(
|
||
TextSpan(text: content.substring(runStart, i), style: segStyle),
|
||
);
|
||
}
|
||
spans.add(_animojiSpan(entity, box));
|
||
i += _animojiPlaceholder.length;
|
||
runStart = i;
|
||
}
|
||
if (runStart < segment.end) {
|
||
spans.add(
|
||
TextSpan(
|
||
text: content.substring(runStart, segment.end),
|
||
style: segStyle,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
return TextSpan(style: baseStyle, children: spans);
|
||
}
|
||
|
||
WidgetSpan _animojiSpan(_AnimojiEntity entity, double box) {
|
||
return WidgetSpan(
|
||
alignment: PlaceholderAlignment.middle,
|
||
child: SizedBox(
|
||
key: ValueKey('composer-animoji-${entity.uid}'),
|
||
width: box,
|
||
height: box,
|
||
child: LottieImage(
|
||
lottieUrl: entity.lottieUrl,
|
||
size: box,
|
||
memCacheWidth: 120,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|