feat: работа с текстом
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
enum TextEntityKind { mention, phone, card }
|
||||
|
||||
class TextEntity {
|
||||
final TextEntityKind kind;
|
||||
final int start;
|
||||
final int end;
|
||||
final String value;
|
||||
|
||||
const TextEntity({
|
||||
required this.kind,
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
int get length => end - start;
|
||||
}
|
||||
|
||||
typedef TextSpanRange = ({int start, int end});
|
||||
|
||||
final RegExp _cardPattern = RegExp(
|
||||
r'(?<![\d.,])\d(?:[ -]?\d){12,18}(?![\d.,])',
|
||||
);
|
||||
final RegExp _phonePattern = RegExp(
|
||||
r'(?<![\d.,])(?:\+\d(?:[ ()-]{0,3}\d){9,14}|[78](?:[ ()-]{0,3}\d){10})(?![\d.,])',
|
||||
);
|
||||
final RegExp _mentionPattern = RegExp(r'(?<![\w@/])@([A-Za-z0-9_]{2,32})');
|
||||
|
||||
const Map<String, String> _cardBrands = {
|
||||
'MIR': 'МИР',
|
||||
'VISA': 'Visa',
|
||||
'MASTERCARD': 'Mastercard',
|
||||
'MAESTRO': 'Maestro',
|
||||
'AMEX': 'American Express',
|
||||
'UNIONPAY': 'UnionPay',
|
||||
'JCB': 'JCB',
|
||||
'DINERS': 'Diners Club',
|
||||
'DISCOVER': 'Discover',
|
||||
};
|
||||
|
||||
String? cardBrand(String digits) {
|
||||
if (digits.length < 13) return null;
|
||||
int prefix(int length) => int.parse(digits.substring(0, length));
|
||||
|
||||
final p1 = prefix(1);
|
||||
final p2 = prefix(2);
|
||||
final p3 = prefix(3);
|
||||
final p4 = prefix(4);
|
||||
final p6 = digits.length >= 6 ? prefix(6) : 0;
|
||||
|
||||
if (p4 >= 2200 && p4 <= 2204) return 'MIR';
|
||||
if (p1 == 4) return 'VISA';
|
||||
if (p2 >= 51 && p2 <= 55) return 'MASTERCARD';
|
||||
if (p4 >= 2221 && p4 <= 2720) return 'MASTERCARD';
|
||||
if (p2 == 34 || p2 == 37) return 'AMEX';
|
||||
if (p2 == 62) return 'UNIONPAY';
|
||||
if (p4 >= 3528 && p4 <= 3589) return 'JCB';
|
||||
if (p3 >= 300 && p3 <= 305) return 'DINERS';
|
||||
if (p2 == 36 || p2 == 38 || p2 == 39) return 'DINERS';
|
||||
if (p4 == 6011 || p2 == 65) return 'DISCOVER';
|
||||
if (p3 >= 644 && p3 <= 649) return 'DISCOVER';
|
||||
if (p6 >= 622126 && p6 <= 622925) return 'DISCOVER';
|
||||
if (p4 == 5018 || p4 == 5020 || p4 == 5038 || p4 == 6304) return 'MAESTRO';
|
||||
if (p4 == 6759 || (p4 >= 6761 && p4 <= 6763)) return 'MAESTRO';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? cardBrandTitle(String digits) {
|
||||
final brand = cardBrand(digits);
|
||||
return brand == null ? null : _cardBrands[brand];
|
||||
}
|
||||
|
||||
String cardMask(String digits) {
|
||||
final brand = cardBrand(digits) ?? 'CARD';
|
||||
final tail = digits.length >= 4
|
||||
? digits.substring(digits.length - 4)
|
||||
: digits;
|
||||
return '$brand*$tail';
|
||||
}
|
||||
|
||||
String formatCardNumber(String digits) {
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < digits.length; i++) {
|
||||
if (i > 0 && i % 4 == 0) buffer.write(' ');
|
||||
buffer.write(digits[i]);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
bool isLuhnValid(String digits) {
|
||||
if (digits.length < 12) return false;
|
||||
var sum = 0;
|
||||
var double = false;
|
||||
for (var i = digits.length - 1; i >= 0; i--) {
|
||||
var value = digits.codeUnitAt(i) - 0x30;
|
||||
if (value < 0 || value > 9) return false;
|
||||
if (double) {
|
||||
value *= 2;
|
||||
if (value > 9) value -= 9;
|
||||
}
|
||||
sum += value;
|
||||
double = !double;
|
||||
}
|
||||
return sum % 10 == 0;
|
||||
}
|
||||
|
||||
String _digitsOf(String raw) {
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < raw.length; i++) {
|
||||
final code = raw.codeUnitAt(i);
|
||||
if (code >= 0x30 && code <= 0x39) buffer.writeCharCode(code);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
bool _mayContainEntities(String text) {
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
final code = text.codeUnitAt(i);
|
||||
if (code == 0x40) return true;
|
||||
if (code >= 0x30 && code <= 0x39) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<TextEntity> detectTextEntities(
|
||||
String text, {
|
||||
Iterable<TextSpanRange> skip = const [],
|
||||
}) {
|
||||
if (text.isEmpty || !_mayContainEntities(text)) return const [];
|
||||
|
||||
final taken = <TextSpanRange>[...skip];
|
||||
bool free(int start, int end) =>
|
||||
!taken.any((r) => start < r.end && end > r.start);
|
||||
|
||||
final found = <TextEntity>[];
|
||||
|
||||
void collect(
|
||||
RegExp pattern,
|
||||
TextEntityKind kind,
|
||||
String? Function(RegExpMatch match) valueOf,
|
||||
) {
|
||||
for (final match in pattern.allMatches(text)) {
|
||||
if (!free(match.start, match.end)) continue;
|
||||
final value = valueOf(match);
|
||||
if (value == null) continue;
|
||||
taken.add((start: match.start, end: match.end));
|
||||
found.add(
|
||||
TextEntity(
|
||||
kind: kind,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
collect(_cardPattern, TextEntityKind.card, (match) {
|
||||
final digits = _digitsOf(match.group(0)!);
|
||||
if (digits.length < 13 || digits.length > 19) return null;
|
||||
if (cardBrand(digits) == null) return null;
|
||||
if (!isLuhnValid(digits)) return null;
|
||||
return digits;
|
||||
});
|
||||
|
||||
collect(_phonePattern, TextEntityKind.phone, (match) {
|
||||
final digits = _digitsOf(match.group(0)!);
|
||||
if (digits.length < 10 || digits.length > 15) return null;
|
||||
return '+$digits';
|
||||
});
|
||||
|
||||
collect(_mentionPattern, TextEntityKind.mention, (match) => match.group(1));
|
||||
|
||||
found.sort((a, b) => a.start.compareTo(b.start));
|
||||
return found;
|
||||
}
|
||||
|
||||
bool hasTextEntities(String text, {Iterable<TextSpanRange> skip = const []}) =>
|
||||
detectTextEntities(text, skip: skip).isNotEmpty;
|
||||
@@ -9,6 +9,7 @@ enum TextFormat {
|
||||
quote,
|
||||
link,
|
||||
animoji,
|
||||
userMention,
|
||||
}
|
||||
|
||||
const Map<TextFormat, String> _formatToServer = {
|
||||
@@ -20,6 +21,7 @@ const Map<TextFormat, String> _formatToServer = {
|
||||
TextFormat.quote: 'QUOTE',
|
||||
TextFormat.link: 'LINK',
|
||||
TextFormat.animoji: 'ANIMOJI',
|
||||
TextFormat.userMention: 'USER_MENTION',
|
||||
};
|
||||
|
||||
final Map<String, TextFormat> _serverToFormat = {
|
||||
@@ -35,12 +37,16 @@ class FormatRange {
|
||||
final TextFormat format;
|
||||
final int start;
|
||||
final int length;
|
||||
final int? entityId;
|
||||
final String? entityName;
|
||||
final Map<String, dynamic>? attributes;
|
||||
|
||||
const FormatRange({
|
||||
required this.format,
|
||||
required this.start,
|
||||
required this.length,
|
||||
this.entityId,
|
||||
this.entityName,
|
||||
this.attributes,
|
||||
});
|
||||
|
||||
@@ -60,6 +66,8 @@ class FormatRange {
|
||||
'type': textFormatToServer(format),
|
||||
'from': start,
|
||||
'length': length,
|
||||
if (entityId != null) 'entityId': entityId,
|
||||
if (entityName != null) 'entityName': entityName,
|
||||
if (attributes != null) 'attributes': attributes,
|
||||
};
|
||||
}
|
||||
@@ -78,11 +86,17 @@ List<FormatRange> parseFormatElements(dynamic raw) {
|
||||
final attributes = attrsRaw is Map
|
||||
? Map<String, dynamic>.from(attrsRaw)
|
||||
: null;
|
||||
final entityId = item['entityId'];
|
||||
final entityName = item['entityName'];
|
||||
result.add(
|
||||
FormatRange(
|
||||
format: format,
|
||||
start: from,
|
||||
length: length,
|
||||
entityId: entityId is int ? entityId : null,
|
||||
entityName: entityName is String && entityName.isNotEmpty
|
||||
? entityName
|
||||
: null,
|
||||
attributes: attributes,
|
||||
),
|
||||
);
|
||||
@@ -134,6 +148,8 @@ class FormatSegment {
|
||||
final Set<TextFormat> formats;
|
||||
final String? url;
|
||||
final String? animojiUrl;
|
||||
final int? mentionId;
|
||||
final String? mentionName;
|
||||
|
||||
const FormatSegment({
|
||||
required this.start,
|
||||
@@ -141,6 +157,8 @@ class FormatSegment {
|
||||
required this.formats,
|
||||
this.url,
|
||||
this.animojiUrl,
|
||||
this.mentionId,
|
||||
this.mentionName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,6 +175,8 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
|
||||
format: range.format,
|
||||
start: start,
|
||||
length: end - start,
|
||||
entityId: range.entityId,
|
||||
entityName: range.entityName,
|
||||
attributes: range.attributes,
|
||||
),
|
||||
);
|
||||
@@ -180,11 +200,17 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
|
||||
final formats = <TextFormat>{};
|
||||
String? url;
|
||||
String? animojiUrl;
|
||||
int? mentionId;
|
||||
String? mentionName;
|
||||
for (final range in clamped) {
|
||||
if (range.start <= start && range.end >= end) {
|
||||
formats.add(range.format);
|
||||
if (range.format == TextFormat.link) url ??= range.url;
|
||||
if (range.format == TextFormat.animoji) animojiUrl ??= range.animojiUrl;
|
||||
if (range.format == TextFormat.userMention) {
|
||||
mentionId ??= range.entityId;
|
||||
mentionName ??= range.entityName;
|
||||
}
|
||||
}
|
||||
}
|
||||
segments.add(
|
||||
@@ -194,6 +220,8 @@ List<FormatSegment> segmentizeFormats(String text, List<FormatRange> ranges) {
|
||||
formats: formats,
|
||||
url: url,
|
||||
animojiUrl: animojiUrl,
|
||||
mentionId: mentionId,
|
||||
mentionName: mentionName,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -204,6 +232,7 @@ TextStyle applyTextFormats(
|
||||
TextStyle base,
|
||||
Set<TextFormat> formats, {
|
||||
Color? quoteColor,
|
||||
Color? mentionColor,
|
||||
}) {
|
||||
if (formats.isEmpty) return base;
|
||||
|
||||
@@ -219,11 +248,15 @@ TextStyle applyTextFormats(
|
||||
final isItalic = formats.contains(TextFormat.emphasized) ||
|
||||
formats.contains(TextFormat.quote);
|
||||
|
||||
final isMention = formats.contains(TextFormat.userMention);
|
||||
|
||||
return base.copyWith(
|
||||
fontWeight: formats.contains(TextFormat.strong) ? FontWeight.w700 : null,
|
||||
fontStyle: isItalic ? FontStyle.italic : null,
|
||||
fontFamily: formats.contains(TextFormat.monospaced) ? 'monospace' : null,
|
||||
color: formats.contains(TextFormat.quote) ? quoteColor : null,
|
||||
color: isMention
|
||||
? mentionColor
|
||||
: (formats.contains(TextFormat.quote) ? quoteColor : null),
|
||||
decoration: decorations.isEmpty
|
||||
? null
|
||||
: TextDecoration.combine(decorations),
|
||||
|
||||
Reference in New Issue
Block a user