Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions lib/src/emoji_picker_internal_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,22 @@ class EmojiPickerInternalUtils {
prefs.setString('recent', jsonEncode([]));
}

/// Returns the last remembered skin tone modifier, or `null` if none stored
Future<String?> getRememberedSkinTone() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('skin_tone');
}

/// Persists the remembered skin tone modifier. Passing `null` clears it.
Future<void> 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, ''));
Expand Down
50 changes: 49 additions & 1 deletion lib/src/emoji_picker_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>.empty(growable: true)
// Basic emoji without gender (until char 2)
..addAll(codeUnits.sublist(0, min(codeUnits.length, 2)))
Expand All @@ -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<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,
Expand Down
49 changes: 45 additions & 4 deletions lib/src/emoji_view/default_emoji_picker_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ class _DefaultEmojiPickerViewState extends State<DefaultEmojiPickerView>
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() {
Expand Down Expand Up @@ -49,9 +54,23 @@ class _DefaultEmojiPickerViewState extends State<DefaultEmojiPickerView>
_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) {
Expand Down Expand Up @@ -206,12 +225,18 @@ class _DefaultEmojiPickerViewState extends State<DefaultEmojiPickerView>
),
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,
Expand Down Expand Up @@ -251,7 +276,23 @@ class _DefaultEmojiPickerViewState extends State<DefaultEmojiPickerView>
}

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);
}
}
53 changes: 49 additions & 4 deletions lib/src/search_view/search_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@ class SearchViewState<T extends SearchView> extends State<T>
/// Search results
final results = List<Emoji>.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
Expand All @@ -51,6 +56,18 @@ class SearchViewState<T extends SearchView> extends State<T>
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();
Expand All @@ -66,17 +83,29 @@ class SearchViewState<T extends SearchView> extends State<T>
..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,
Expand All @@ -102,10 +131,26 @@ class SearchViewState<T extends SearchView> extends State<T>
}

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');
Expand Down
18 changes: 16 additions & 2 deletions lib/src/skin_tones/skin_tone_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Loading
Loading