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 _allAvailableEmojiEntities = []; RegExp? _emojiRegExp; /// Returns list of recently used emoji from cache Future> getRecentEmojis() => Future.value(EmojiPickerInternalUtils().getRecentEmojis()); /// Filters out emojis not supported on the platform Future> filterUnsupported(List data) => Future.value(EmojiPickerInternalUtils().filterUnsupported(data)); /// Search for related emoticons based on keywords Future> searchEmoji( String search, List 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 addEmojiToRecentlyUsed({ required GlobalKey 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 setEmojiTextStyle( String text, { required TextStyle emojiStyle, TextStyle? parentStyle, }) { final composedEmojiStyle = (parentStyle ?? const TextStyle()) .merge(DefaultEmojiTextStyle) .merge(emojiStyle); final spans = []; 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.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 getRememberedSkinTone() => EmojiPickerInternalUtils().getRememberedSkinTone(); /// Persists the remembered skin tone modifier. Passing `null` clears it. Future setRememberedSkinTone(String? skinTone) => EmojiPickerInternalUtils().setRememberedSkinTone(skinTone); /// Clears the list of recent emojis Future clearRecentEmojis({ required GlobalKey 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); } }