Better emoji picker #73

Merged
Henry-Hiles merged 11 commits from quad/feat/better-emoji-picker into main 2026-09-16 14:47:46 -04:00
10 changed files with 69 additions and 162 deletions

View file

@ -1,84 +0,0 @@
import "dart:convert";
import "package:emoji_text_field/models/emoji_category.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:http/http.dart";
import "package:nexus/models/emoji.dart";
typedef EmojiTuple = (IMap<String, EmojiCategory>, IMap<String, List<String>>);
class EmojiController extends AsyncNotifier<EmojiTuple> {
@override
Future<EmojiTuple> build() async {
final response = await get(
.https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"),
);
if (response.statusCode != 200) {
throw Exception("Failed to load emoji data");
}
final data = json.decode(response.body);
final entries = (data as List)
.cast<Map<String, dynamic>>()
.map(Emoji.fromJson)
.toIList();
final categoryMap = entries.fold<IMap<String, IList<String>>>(
.new(),
(acc, entry) => acc.update(
entry.category,
(list) => list.add(entry.emoji),
ifAbsent: () => .new([entry.emoji]),
),
);
final keywordMap = entries.fold<IMap<String, IList<String>>>(
.new(),
(acc, entry) => acc.add(
entry.emoji,
.new([...entry.tags, ...entry.aliases, entry.description]),
),
);
final customCategories = IMap.fromEntries(
categoryMap.entries.map(
(entry) => MapEntry(
entry.key,
EmojiCategory(
name: entry.key,
icon: switch (entry.key) {
"Smileys & Emotion" => Icons.emoji_emotions,
"People & Body" => Icons.emoji_people,
"Animals & Nature" => Icons.emoji_nature,
"Food & Drink" => Icons.emoji_food_beverage,
"Travel & Places" => Icons.travel_explore,
"Activities" => Icons.sports_soccer,
"Objects" => Icons.emoji_objects,
"Symbols" => Icons.emoji_symbols,
"Flags" => Icons.emoji_flags,
_ => Icons.category,
},
emojis: entry.value.toList(growable: false),
),
),
),
);
final customKeywords = IMap(
.fromEntries(
keywordMap.entries.map(
(e) => .new(e.key, e.value.toList(growable: false)),
),
),
);
return (customCategories, customKeywords);
}
static final provider = AsyncNotifierProvider<EmojiController, EmojiTuple>(
EmojiController.new,
);
}

View file

@ -1,18 +1,25 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:flutter/widgets.dart";
part "emoji.freezed.dart";
part "emoji.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
@JsonSerializable(createToJson: false)
class Emoji({
required final String emoji,
required final String category,
@JsonKey(
fromJson: Emoji.widgetFromJson,
readValue: Emoji.readWidgetValueFromJson,
)
required final Widget widget,
@JsonKey(name: "emoji") required final String value,
required final IList<String> aliases,
required final String description,
required final IList<String> tags,
}) with _$Emoji {
Map<String, Object?> toJson() => _$EmojiToJson(this);
static Widget widgetFromJson(String emoji) => Text(emoji);
static String readWidgetValueFromJson(Map<dynamic, dynamic> json, _) =>
json["emoji"];
factory Emoji.fromJson(Map<String, Object?> json) => _$EmojiFromJson(json);
}

View file

@ -0,0 +1,12 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/emoji.dart";
import "package:flutter/widgets.dart";
part "emoji_category.freezed.dart";
@Freezed(toJson: false, fromJson: false)
class EmojiCategory({
required final Widget icon,
required final String name,
required final IList<Emoji> emojis,
}) with _$EmojiCategory;

View file

@ -1,6 +1,5 @@
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/emoji.dart";
import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/widgets/appbar.dart";
@ -17,8 +16,6 @@ class const ChatPage({super.key}) extends ConsumerWidget {
final initComplete = ref.watch(InitCompleteController.provider);
final roomId = ref.watch(KeyController.provider(KeyController.roomKey));
ref.read(EmojiController.provider);
return SafeArea(
child: Scaffold(
appBar: initComplete ? null : Appbar(),

View file

@ -2,6 +2,7 @@ import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:file_selector/file_selector.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
@ -15,7 +16,6 @@ import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/mention_overlay.dart";
import "package:nexus/widgets/composer/relation_preview.dart";
import "package:nexus/widgets/emoji_picker_button.dart";
import "package:nexus/main.dart";
class const Composer(
@ -119,10 +119,19 @@ class const Composer(
),
)
? [
EmojiPickerButton(
IconButton(
onPressed: () => showModalBottomSheet(
isScrollControlled: true,
context: context,
onSelection: (_) => node?.requestFocus(),
controller: controller.value,
builder: (context) => EmojiPicker(
onSelection: (value) {
Navigator.of(context).pop();
controller.value.text += value;
node?.requestFocus();
},
),
),
icon: Icon(Icons.emoji_emotions),
),
PopupMenuButton(
tooltip: "Add media",

View file

@ -1,46 +0,0 @@
import "package:emoji_text_field/emoji_text_field.dart";
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/emoji.dart";
final class const EmojiPickerButton({
required final BuildContext context,
final TextEditingController? controller,
final void Function(String emoji)? onSelection,
final VoidCallback? onPressed,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(_, WidgetRef ref) => IconButton(
onPressed: () async {
onPressed?.call();
final controller = this.controller ?? .new();
final emojis = await ref.watch(EmojiController.provider.future);
if (context.mounted) {
showModalBottomSheet(
context: context,
builder: (context) => EmojiKeyboardView(
config: .new(
showRecentTab: false,
customCategories: emojis.$1.unlock,
customKeywords: emojis.$2.unlock,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
height: 600,
),
textController: controller
..addListener(() {
// Without this, there will sometimes be a debugLocked is not true error sometimes
// It might be preferable to use a microtask instead of a `Future.delayed`.
Future.delayed(.zero, () {
if (context.mounted) Navigator.of(context).pop();
});
onSelection?.call(controller.text);
}),
),
);
}
},
icon: Icon(Icons.emoji_emotions),
);
}

View file

@ -17,7 +17,7 @@ import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/composer.dart";
import "package:nexus/widgets/emoji_picker_button.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/member_list.dart";
@ -234,11 +234,26 @@ final class const RoomChat({
icon: Text(emoji),
),
),
EmojiPickerButton(
IconButton(
icon: Icon(Icons.emoji_emotions),
onPressed: () {
// TODO: More adaptive, e.g. dialog on desktop
Navigator.of(context).pop();
showModalBottomSheet(
isScrollControlled: true,
context: context,
onPressed: Navigator.of(context).pop,
onSelection: (emoji) =>
notifier.sendReaction(emoji, event).onError(showError),
builder: (context) => EmojiPicker(
onSelection: (value) {
Navigator.of(context).pop();
notifier
.sendReaction(value, event)
.onError(showError);
},
allowFreeText: true,
),
);
},
),
],
),

View file

@ -297,15 +297,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.8"
emoji_text_field:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "5f7baaf8a6f059ec3ab8ff0f5d02339b00bf6997"
url: "https://github.com/Henry-Hiles/emoji_text_field"
source: git
version: "1.0.0"
encrypt:
dependency: transitive
description:
@ -903,6 +894,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.0"
material_emoji_picker:
dependency: "direct main"
description:
name: material_emoji_picker
sha256: "1547195a419134cf0b2396d119dc873589804f461424917b1b61a0966bfb7e5a"
url: "https://pub.dev"
source: hosted
version: "1.0.0+1"
material_ui:
dependency: "direct main"
description:

View file

@ -59,9 +59,6 @@ dependencies:
http: 1.6.0
flutter_linkify: 6.0.0
linkify: 5.0.0
emoji_text_field:
git:
url: https://github.com/Henry-Hiles/emoji_text_field
flutter_blurhash: 0.9.1
super_sliver_list: 0.4.1
media_kit: 1.2.6
@ -83,6 +80,7 @@ dependencies:
unifiedpush_storage_shared_preferences: 1.0.0
flutter_local_notifications: 22.3.0
flutter_local_notifications_linux: 8.0.1
material_emoji_picker: 1.0.0+1
dev_dependencies:
build_runner: 2.16.1

View file

@ -7,7 +7,7 @@ import "package:nexus/helpers/extensions/get_xcode_sdk.dart";
void main(List<String> args) async {
final repoDir = Directory.fromUri(Platform.script.resolve("../gomuks"));
print("Generating FFI Bindings...");
print("Generating FFI bindings...");
final libclangPath = Platform.environment["LIBCLANG_PATH"];
FfiGenerator(
@ -40,5 +40,5 @@ void main(List<String> args) async {
),
),
);
print("Done!");
print("FFI bindings generated!");
}