twonly-app-dependencies/emoji_picker_flutter/lib/src/emoji_picker_utils.dart
2026-08-31 22:03:36 +02:00

253 lines
8.4 KiB
Dart

import 'dart:math';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:emoji_picker_flutter/src/emoji_picker_internal_utils.dart';
import 'package:flutter/material.dart';
/// Emoji Regex
/// Keycap Sequence '((\u0023|\u002a|[\u0030-\u0039])\ufe0f\u20e3){1}'
/// Issue: https://github.com/flutter/flutter/issues/36062
const EmojiRegex =
r'((\u0023|\u002a|[\u0030-\u0039])\ufe0f\u20e3){1}|\p{Emoji}|\u200D|\uFE0F';
/// Helper class that provides extended usage
class EmojiPickerUtils {
/// Singleton Constructor
factory EmojiPickerUtils() {
return _singleton;
}
EmojiPickerUtils._internal();
static final EmojiPickerUtils _singleton = EmojiPickerUtils._internal();
static final RegExp _whitespaceRegExp = RegExp(r'\s+');
final List<Emoji> _allAvailableEmojiEntities = [];
RegExp? _emojiRegExp;
/// Returns list of recently used emoji from cache
Future<List<RecentEmoji>> getRecentEmojis() =>
Future.value(EmojiPickerInternalUtils().getRecentEmojis());
/// Filters out emojis not supported on the platform
Future<List<CategoryEmoji>> filterUnsupported(List<CategoryEmoji> data) =>
Future.value(EmojiPickerInternalUtils().filterUnsupported(data));
/// Search for related emoticons based on keywords
Future<List<Emoji>> searchEmoji(
String search,
List<CategoryEmoji> emojiSet, {
bool checkPlatformCompatibility = true,
}) async {
if (search.isEmpty) return [];
if (_allAvailableEmojiEntities.isEmpty) {
final emojiPickerInternalUtils = EmojiPickerInternalUtils();
final data = [...emojiSet]
..removeWhere((e) => e.category == Category.RECENT);
final availableCategoryEmoji = checkPlatformCompatibility
? await emojiPickerInternalUtils.filterUnsupported(data)
: data;
// Set all the emoji entities
for (var emojis in availableCategoryEmoji) {
_allAvailableEmojiEntities.addAll(emojis.emoji);
}
}
// Split the input string into a list of lowercase keywords
final keywordSet = search
.split(_whitespaceRegExp)
.where((e) => e.isNotEmpty)
.map((e) => e.toLowerCase())
.toSet();
if (keywordSet.isEmpty) return [];
return _allAvailableEmojiEntities.where((emoji) {
// Perform lowercasing of emoji keywords once
final emojiKeywordSet = emoji.keywords
.map((e) => e.toLowerCase())
.toSet();
// Check if first keyword is a prefix of any emoji keyword
final matchFirstKeyword = emojiKeywordSet.any(
(emojiKeyword) => emojiKeyword.startsWith(keywordSet.first),
);
var matchKeywords = false;
if (matchFirstKeyword) {
// Check if each search keyword is a prefix of any emoji keyword
// start from second keyword, returns true if empty (only 1 keyword)
matchKeywords = keywordSet.skip(1).every((keyword) {
return emojiKeywordSet.any(
(emojiKeyword) => emojiKeyword.startsWith(keyword),
);
});
} else {
matchKeywords = false;
}
// Check for an exact match with emoji character
final matchEmoji = emoji.emoji == search.trim();
return matchKeywords || matchEmoji;
}).toList();
}
/// Add an emoji to recently used list or increase its counter
Future<void> addEmojiToRecentlyUsed({
required GlobalKey<EmojiPickerState> key,
required Emoji emoji,
Config config = const Config(),
}) async {
return EmojiPickerInternalUtils()
.addEmojiToRecentlyUsed(emoji: emoji, config: config)
.then(
(recentEmojiList) =>
key.currentState?.updateRecentEmoji(recentEmojiList),
);
}
/// Produce a list of spans to adjust style for emoji characters.
/// Spans enclosing emojis will have [parentStyle] combined with [emojiStyle].
/// Other spans will not have an explicit style (this method does not set
/// [parentStyle] to the whole text.
List<InlineSpan> setEmojiTextStyle(
String text, {
required TextStyle emojiStyle,
TextStyle? parentStyle,
}) {
final composedEmojiStyle = (parentStyle ?? const TextStyle())
.merge(DefaultEmojiTextStyle)
.merge(emojiStyle);
final spans = <TextSpan>[];
final matches = getEmojiRegex().allMatches(text).toList();
var cursor = 0;
for (final match in matches) {
if (cursor != match.start) {
// Non emoji text + following emoji
spans
..add(
TextSpan(
text: text.substring(cursor, match.start),
style: parentStyle,
),
)
..add(
TextSpan(
text: text.substring(match.start, match.end),
style: composedEmojiStyle,
),
);
} else {
if (spans.isEmpty) {
// Create new span if no previous emoji TextSpan exists
spans.add(
TextSpan(
text: text.substring(match.start, match.end),
style: composedEmojiStyle,
),
);
} else {
// Update last span if current text is still emoji
final lastIndex = spans.length - 1;
final lastText = spans[lastIndex].text ?? '';
final currentText = text.substring(match.start, match.end);
spans[lastIndex] = TextSpan(
text: '$lastText$currentText',
style: composedEmojiStyle,
);
}
}
// Update cursor
cursor = match.end;
}
// Add remaining text
if (cursor != text.length) {
spans.add(
TextSpan(text: text.substring(cursor, text.length), style: parentStyle),
);
}
return spans;
}
/// Applies skin tone to given emoji
///
/// Any existing skin tone modifier is stripped first, so re-applying a tone
/// to an already toned glyph produces a valid single-modifier sequence
/// instead of an invalid double-modifier one (e.g. 👋🏻🏽).
Emoji applySkinTone(Emoji emoji, String color) {
final codeUnits = removeSkinTone(emoji).emoji.codeUnits;
var result = List<int>.empty(growable: true)
// Basic emoji without gender (until char 2)
..addAll(codeUnits.sublist(0, min(codeUnits.length, 2)))
// Skin tone
..addAll(color.codeUnits);
// add the rest of the emoji (gender, etc.) again
if (codeUnits.length >= 2) {
result.addAll(codeUnits.sublist(2));
}
return emoji.copyWith(emoji: String.fromCharCodes(result));
}
/// Removes any skin tone modifier from the given emoji
Emoji removeSkinTone(Emoji emoji) =>
EmojiPickerInternalUtils().removeSkinTone(emoji);
/// Returns the emoji that should be displayed (and selected) in the grid,
/// recents and search results.
///
/// When [skinToneConfig] remembers a tone and [rememberedSkinTone] is set,
/// the toned glyph is returned; otherwise the original emoji is returned
/// unchanged. The result keeps [Emoji.hasSkinTone] intact so the indicator
/// and long-press picker keep working on the cell.
Emoji applyDisplaySkinTone(
Emoji emoji,
SkinToneConfig skinToneConfig,
String? rememberedSkinTone,
) {
if (!skinToneConfig.enabled ||
!skinToneConfig.rememberSkinTone ||
rememberedSkinTone == null ||
!emoji.hasSkinTone) {
return emoji;
}
return applySkinTone(emoji, rememberedSkinTone);
}
/// Returns the skin tone modifier contained in [emoji], or `null` when the
/// emoji carries no skin tone.
String? extractSkinTone(Emoji emoji) {
for (final tone in SkinTone.values) {
if (emoji.emoji.contains(tone)) {
return tone;
}
}
return null;
}
/// Returns the last remembered skin tone modifier, or `null` if none.
Future<String?> getRememberedSkinTone() =>
EmojiPickerInternalUtils().getRememberedSkinTone();
/// Persists the remembered skin tone modifier. Passing `null` clears it.
Future<void> setRememberedSkinTone(String? skinTone) =>
EmojiPickerInternalUtils().setRememberedSkinTone(skinTone);
/// Clears the list of recent emojis
Future<void> clearRecentEmojis({
required GlobalKey<EmojiPickerState> key,
}) async {
return await EmojiPickerInternalUtils()
.clearRecentEmojisInLocalStorage()
.then((_) => key.currentState?.updateRecentEmoji([], refresh: true));
}
/// Returns the emoji regex
/// Based on https://unicode.org/reports/tr51/
RegExp getEmojiRegex() {
return _emojiRegExp ??= RegExp(EmojiRegex, unicode: true);
}
}