diff --git a/CHANGELOG.md b/CHANGELOG.md index b1bfb1a..1b3a36f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ - Bump minimum Flutter version to `3.41.8` (Dart `3.11.5`) - Fix memory leaks, race conditions, and regex recompilation - Clip picker overflow so it renders cleanly when constrained to a smaller height (#256) +- Add `rememberSkinTone` to `SkinToneConfig` to persist the last selected skin tone and re-apply it as the default in the grid, recents and search +- Fix `applySkinTone` producing invalid double-modifier sequences when applied to an already toned glyph (existing tone is now stripped first) +- Skin tone long-press picker now always applies the new tone to the base glyph ## 4.4.0 diff --git a/README.md b/README.md index ef4a45c..9abea55 100644 --- a/README.md +++ b/README.md @@ -121,11 +121,26 @@ All examples can be found [here](https://github.com/Fintasys/emoji_picker_flutte ## SkinTone Config -| property | description | default | -| --------------------- | ------------------------------------------------------------ | ------------ | -| enableSkinTones | Enable feature to select a skin tone of certain emoji's | true | -| dialogBackgroundColor | The background color of the skin tone dialog | Colors.white | -| indicatorColor | Color of the small triangle next to multiple skin tone emoji | Colors.grey | +| property | description | default | +| --------------------- | -------------------------------------------------------------------- | ------------ | +| enabled | Enable feature to select a skin tone of certain emoji's | true | +| dialogBackgroundColor | The background color of the skin tone dialog | Colors.white | +| indicatorColor | Color of the small triangle next to multiple skin tone emoji | Colors.grey | +| rememberSkinTone | Remember the last selected skin tone and re-apply it as the default | false | + +### Remembering a chosen skin tone + +By default the grid, recents and search render the base (default) glyph. Set +`rememberSkinTone: true` to have the picker persist the last tone chosen via the +long-press picker (stored in `SharedPreferences`) and re-apply it as the default +for every skin-tone-capable emoji on the next launch. Selecting the base +(no-tone) glyph clears the remembered tone. + +```dart +SkinToneConfig( + rememberSkinTone: true, +), +``` ## Category View Config diff --git a/lib/src/emoji_picker_internal_utils.dart b/lib/src/emoji_picker_internal_utils.dart index 4eb0608..97a5687 100644 --- a/lib/src/emoji_picker_internal_utils.dart +++ b/lib/src/emoji_picker_internal_utils.dart @@ -136,6 +136,22 @@ class EmojiPickerInternalUtils { prefs.setString('recent', jsonEncode([])); } + /// Returns the last remembered skin tone modifier, or `null` if none stored + Future getRememberedSkinTone() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('skin_tone'); + } + + /// Persists the remembered skin tone modifier. Passing `null` clears it. + Future setRememberedSkinTone(String? skinTone) async { + final prefs = await SharedPreferences.getInstance(); + if (skinTone == null) { + await prefs.remove('skin_tone'); + } else { + await prefs.setString('skin_tone', skinTone); + } + } + /// Remove skin tone from given emoji Emoji removeSkinTone(Emoji emoji) { return emoji.copyWith(emoji: emoji.emoji.replaceFirst(_skinToneRegExp, '')); diff --git a/lib/src/emoji_picker_utils.dart b/lib/src/emoji_picker_utils.dart index 7bb1b8c..7167654 100644 --- a/lib/src/emoji_picker_utils.dart +++ b/lib/src/emoji_picker_utils.dart @@ -175,8 +175,12 @@ class EmojiPickerUtils { } /// 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 = emoji.emoji.codeUnits; + 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))) @@ -189,6 +193,50 @@ class EmojiPickerUtils { 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, diff --git a/lib/src/emoji_view/default_emoji_picker_view.dart b/lib/src/emoji_view/default_emoji_picker_view.dart index b71e7ce..aad8928 100644 --- a/lib/src/emoji_view/default_emoji_picker_view.dart +++ b/lib/src/emoji_view/default_emoji_picker_view.dart @@ -20,6 +20,11 @@ class _DefaultEmojiPickerViewState extends State late TabController _tabController; late PageController _pageController; final _scrollController = ScrollController(); + final _utils = EmojiPickerUtils(); + + /// Last remembered skin tone, applied to skin-tone-capable emoji for + /// display and selection when [SkinToneConfig.rememberSkinTone] is enabled. + String? _rememberedSkinTone; @override void initState() { @@ -49,9 +54,23 @@ class _DefaultEmojiPickerViewState extends State _onCategoryNavigationChanged, ); + _loadRememberedSkinTone(); + super.initState(); } + void _loadRememberedSkinTone() { + if (!widget.config.skinToneConfig.rememberSkinTone) { + return; + } + _utils.getRememberedSkinTone().then((tone) { + if (!mounted || tone == null) { + return; + } + setState(() => _rememberedSkinTone = tone); + }); + } + void _onCategoryNavigationChanged() { final targetCategory = widget.state.categoryNavigationNotifier.value; if (targetCategory != null) { @@ -206,12 +225,18 @@ class _DefaultEmojiPickerViewState extends State ), itemCount: categoryEmoji.emoji.length, itemBuilder: (context, index) { + // Apply a remembered/default skin tone for display and selection. + // Falls back to the base glyph when no tone is configured. + final displayEmoji = _utils.applyDisplaySkinTone( + categoryEmoji.emoji[index], + widget.config.skinToneConfig, + _rememberedSkinTone, + ); return addSkinToneTargetIfAvailable( - hasSkinTone: categoryEmoji.emoji[index].hasSkinTone, - linkKey: - categoryEmoji.category.name + categoryEmoji.emoji[index].emoji, + hasSkinTone: displayEmoji.hasSkinTone, + linkKey: categoryEmoji.category.name + displayEmoji.emoji, child: EmojiCell.fromConfig( - emoji: categoryEmoji.emoji[index], + emoji: displayEmoji, emojiSize: emojiSize, emojiBoxSize: emojiBoxSize, categoryEmoji: categoryEmoji, @@ -251,7 +276,23 @@ class _DefaultEmojiPickerViewState extends State } void _onSkinTonedEmojiSelected(Category? category, Emoji emoji) { + _rememberSkinToneIfEnabled(emoji); widget.state.onEmojiSelected(category, emoji); closeSkinToneOverlay(); } + + /// Persists and re-applies the skin tone of the selected [emoji] when + /// [SkinToneConfig.rememberSkinTone] is enabled. Selecting a + /// skin-tone-capable base glyph (no modifier) clears the remembered tone. + void _rememberSkinToneIfEnabled(Emoji emoji) { + if (!widget.config.skinToneConfig.rememberSkinTone || !emoji.hasSkinTone) { + return; + } + final tone = _utils.extractSkinTone(emoji); + if (tone == _rememberedSkinTone) { + return; + } + _utils.setRememberedSkinTone(tone); + setState(() => _rememberedSkinTone = tone); + } } diff --git a/lib/src/search_view/search_view.dart b/lib/src/search_view/search_view.dart index f7d7b58..8f0da4d 100644 --- a/lib/src/search_view/search_view.dart +++ b/lib/src/search_view/search_view.dart @@ -30,9 +30,14 @@ class SearchViewState extends State /// Search results final results = List.empty(growable: true); + /// Last remembered skin tone, applied to skin-tone-capable emoji for display + /// and selection when [SkinToneConfig.rememberSkinTone] is enabled. + String? _rememberedSkinTone; + @override void initState() { super.initState(); + _loadRememberedSkinTone(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; // Auto focus textfield @@ -51,6 +56,18 @@ class SearchViewState extends State super.dispose(); } + void _loadRememberedSkinTone() { + if (!widget.config.skinToneConfig.rememberSkinTone) { + return; + } + utils.getRememberedSkinTone().then((tone) { + if (!mounted || tone == null) { + return; + } + setState(() => _rememberedSkinTone = tone); + }); + } + /// On text input changed callback void onTextInputChanged(String text) { links.clear(); @@ -66,17 +83,29 @@ class SearchViewState extends State ..clear() ..addAll(emojis); results.asMap().entries.forEach((e) { - links[e.value.emoji] = LayerLink(); + final displayEmoji = utils.applyDisplaySkinTone( + e.value, + widget.config.skinToneConfig, + _rememberedSkinTone, + ); + links[displayEmoji.emoji] = LayerLink(); }); } /// Build emoji cell Widget buildEmoji(Emoji emoji, double emojiSize, double emojiBoxSize) { + // Apply a remembered skin tone for display and selection. + // Falls back to the base glyph when no tone is remembered. + final displayEmoji = utils.applyDisplaySkinTone( + emoji, + widget.config.skinToneConfig, + _rememberedSkinTone, + ); return addSkinToneTargetIfAvailable( - hasSkinTone: emoji.hasSkinTone, - linkKey: emoji.emoji, + hasSkinTone: displayEmoji.hasSkinTone, + linkKey: displayEmoji.emoji, child: EmojiCell.fromConfig( - emoji: emoji, + emoji: displayEmoji, emojiSize: emojiSize, emojiBoxSize: emojiBoxSize, onEmojiSelected: widget.state.onEmojiSelected, @@ -102,10 +131,26 @@ class SearchViewState extends State } void _onSkinTonedEmojiSelected(Category? category, Emoji emoji) { + _rememberSkinToneIfEnabled(emoji); widget.state.onEmojiSelected(category, emoji); closeSkinToneOverlay(); } + /// Persists and re-applies the skin tone of the selected [emoji] when + /// [SkinToneConfig.rememberSkinTone] is enabled. Selecting a + /// skin-tone-capable base glyph (no modifier) clears the remembered tone. + void _rememberSkinToneIfEnabled(Emoji emoji) { + if (!widget.config.skinToneConfig.rememberSkinTone || !emoji.hasSkinTone) { + return; + } + final tone = utils.extractSkinTone(emoji); + if (tone == _rememberedSkinTone) { + return; + } + utils.setRememberedSkinTone(tone); + setState(() => _rememberedSkinTone = tone); + } + @override Widget build(BuildContext context) { throw UnimplementedError('Search View implementation missing'); diff --git a/lib/src/skin_tones/skin_tone_config.dart b/lib/src/skin_tones/skin_tone_config.dart index 20d0d3f..8191d23 100644 --- a/lib/src/skin_tones/skin_tone_config.dart +++ b/lib/src/skin_tones/skin_tone_config.dart @@ -7,6 +7,7 @@ class SkinToneConfig { this.enabled = true, this.dialogBackgroundColor = Colors.white, this.indicatorColor = Colors.grey, + this.rememberSkinTone = false, }); /// Enable feature to select a skin tone of certain emoji's @@ -18,17 +19,30 @@ class SkinToneConfig { /// Color of the small triangle next to multiple skin tone emoji final Color indicatorColor; + /// Remember the last skin tone the user selected. + /// + /// When `true`, the tone chosen via the long-press picker is persisted (in + /// `SharedPreferences`) and re-applied as the default for every + /// skin-tone-capable emoji in the grid, recents and search on the next + /// launch. Selecting the base (no-tone) glyph clears the remembered tone. + /// + /// When `false` (default) the base glyph is always shown, which is the + /// previous behavior. + final bool rememberSkinTone; + @override bool operator ==(other) { return (other is SkinToneConfig) && other.enabled == enabled && other.dialogBackgroundColor == dialogBackgroundColor && - other.indicatorColor == indicatorColor; + other.indicatorColor == indicatorColor && + other.rememberSkinTone == rememberSkinTone; } @override int get hashCode => enabled.hashCode ^ dialogBackgroundColor.hashCode ^ - indicatorColor.hashCode; + indicatorColor.hashCode ^ + rememberSkinTone.hashCode; } diff --git a/test/emoji_picker_flutter_test.dart b/test/emoji_picker_flutter_test.dart index 8dbfd00..67bc6c2 100644 --- a/test/emoji_picker_flutter_test.dart +++ b/test/emoji_picker_flutter_test.dart @@ -26,6 +26,19 @@ void skinToneTests() { ); }); + test('applySkinTone() strips an existing tone before re-applying', () { + // Re-applying a tone to an already toned glyph must not produce an + // invalid double-modifier sequence (e.g. 👋🏻🏽). + expect( + utils.applySkinTone(const Emoji('👍🏻', ''), SkinTone.medium).emoji, + '👍🏽', + ); + expect( + utils.applySkinTone(const Emoji('🏊🏾‍♂️', ''), SkinTone.light).emoji, + '🏊🏻‍♂️', + ); + }); + test('removeSkinTone()', () { expect(internalUtils.removeSkinTone(const Emoji('👍🏻', '')).emoji, '👍'); expect( @@ -37,6 +50,69 @@ void skinToneTests() { '👱‍♀️', ); }); + + test('EmojiPickerUtils.removeSkinTone()', () { + expect(utils.removeSkinTone(const Emoji('👍🏻', '')).emoji, '👍'); + expect(utils.removeSkinTone(const Emoji('👍', '')).emoji, '👍'); + }); + + test('extractSkinTone()', () { + expect(utils.extractSkinTone(const Emoji('👍🏽', '')), SkinTone.medium); + expect( + utils.extractSkinTone(const Emoji('🏊🏾‍♂️', '')), + SkinTone.mediumDark, + ); + // Base glyph without a modifier -> null + expect(utils.extractSkinTone(const Emoji('👍', '')), isNull); + }); + + test('applyDisplaySkinTone()', () { + const toneable = Emoji('👍', '', hasSkinTone: true); + const plain = Emoji('😀', ''); + const remember = SkinToneConfig(rememberSkinTone: true); + + // No remembered tone -> emoji is returned unchanged + expect(utils.applyDisplaySkinTone(toneable, remember, null).emoji, '👍'); + + // Emoji without skin tone support is never modified + expect( + utils.applyDisplaySkinTone(plain, remember, SkinTone.dark).emoji, + '😀', + ); + + // Remembered tone is applied and hasSkinTone is preserved + final toned = utils.applyDisplaySkinTone( + toneable, + remember, + SkinTone.medium, + ); + expect(toned.emoji, '👍🏽'); + expect(toned.hasSkinTone, isTrue); + + // rememberSkinTone disabled -> tone is not applied + expect( + utils + .applyDisplaySkinTone( + toneable, + const SkinToneConfig(rememberSkinTone: false), + SkinTone.medium, + ) + .emoji, + '👍', + ); + + // Skin tones disabled entirely -> tone is not applied + expect( + utils + .applyDisplaySkinTone( + toneable, + const SkinToneConfig(enabled: false, rememberSkinTone: true), + SkinTone.medium, + ) + .emoji, + '👍', + ); + }); } void emojiModelTests() {