Watch
1
0
Fork
You've already forked nexus
0
forked from Nexus/nexus

WIP: New Emoji Picker

This commit is contained in:
istalri 2026-08-01 00:58:57 +02:00
commit 941e1c0e69
7 changed files with 543 additions and 140 deletions

View file

@ -14,8 +14,8 @@ 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";
import "package:nexus/widgets/emote_picker.dart";
class Composer extends HookConsumerWidget {
final String roomId;
@ -123,10 +123,35 @@ class Composer extends HookConsumerWidget {
),
)
? [
EmojiPickerButton(
context: context,
onSelection: (_) => node?.requestFocus(),
controller: controller.value,
IconButton(
icon: Icon(Icons.emoji_emotions),
tooltip: "Insert emoji",
onPressed: () {
showEmotePickerBottomSheet(
context: context,
roomId: roomId,
onSelect: (emojiId) {
final textController = controller.value;
final selection = textController.selection;
final newText = textController.text
.replaceRange(
selection.start,
selection.end,
emojiId,
);
textController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset:
selection.start + emojiId.length,
),
);
node?.requestFocus();
},
);
},
),
PopupMenuButton(
tooltip: "Add media",

View file

@ -1,66 +0,0 @@
import "package:emoji_text_field/emoji_text_field.dart";
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/emoji.dart";
class EmojiPickerButton extends HookConsumerWidget {
final TextEditingController? controller;
final void Function(String emoji)? onSelection;
final VoidCallback? onPressed;
final BuildContext context;
const EmojiPickerButton({
this.controller,
this.onPressed,
this.onSelection,
required this.context,
super.key,
});
@override
Widget build(_, WidgetRef ref) => IconButton(
onPressed: () async {
onPressed?.call();
final tempController = controller ?? TextEditingController();
final shouldDispose = controller == null;
var hasPopped = false;
void handler() {
if (tempController.text.isEmpty || hasPopped) return;
hasPopped = true;
onSelection?.call(tempController.text);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) Navigator.of(context).maybePop();
});
}
final emojis = await ref.watch(EmojiController.provider.future);
if (!context.mounted) {
if (shouldDispose) tempController.dispose();
return;
}
await showModalBottomSheet(
context: context,
builder: (sheetContext) => EmojiKeyboardView(
config: .new(
showRecentTab: false,
customCategories: emojis.$1.unlock,
customKeywords: emojis.$2.unlock,
backgroundColor: Theme.of(
sheetContext,
).colorScheme.surfaceContainer,
height: 600,
),
textController: tempController..addListener(handler),
),
);
tempController.removeListener(handler);
if (shouldDispose) tempController.dispose();
},
icon: Icon(Icons.emoji_emotions),
);
}

View file

@ -0,0 +1,399 @@
import "dart:math" as math;
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart";
import "package:flutter/rendering.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/emoji.dart";
class EmotePicker extends HookConsumerWidget {
final String? roomId;
final void Function(String emoteId) onSelect;
final VoidCallback? onClose;
const EmotePicker({
super.key,
this.roomId,
this.onClose,
required this.onSelect,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
minHeight: math.min(MediaQuery.of(context).size.height * 0.6, 400),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
Divider(height: 1),
// Content
Expanded(
child: ListView(
children: [
EmoteCategoryList(
roomId: roomId,
onSelect: onSelect,
fetchSections: (rid, query) =>
fetchEmojiSections(ref, rid, query),
placeholderText: "emojis & emotes",
),
],
),
),
],
),
),
);
}
}
class EmoteCategoryList extends HookWidget {
final String? roomId;
final void Function(String) onSelect;
final Future<IList<EmoteSection>> Function(String? roomId, String query)
fetchSections;
final String placeholderText;
const EmoteCategoryList({
super.key,
required this.roomId,
required this.onSelect,
required this.fetchSections,
required this.placeholderText,
});
@override
Widget build(BuildContext context) {
final searchController = useTextEditingController(text: "");
final searchFocus = useFocusNode();
final searchText = useState("");
useEffect(() {
void listener() {
searchText.value = searchController.text;
}
searchController.addListener(listener);
return () => searchController.removeListener(listener);
}, [searchController]);
final isSearching = searchText.value.isNotEmpty;
final sectionsFuture = useMemoized(
() => fetchSections(roomId, searchText.value),
[searchText.value],
);
final sectionsSnapshot = useFuture(sectionsFuture);
final sections = sectionsSnapshot.data ?? [].toIList();
final scrollController = useScrollController();
final sectionKeysMap = useState(<String, GlobalKey>{});
final activeCategoryId = useState<String?>(null);
useEffect(() {
if (sections.isEmpty) return null;
bool active = true;
void listener() {
if (!active || !scrollController.hasClients) return;
final scrollPixels = scrollController.offset;
String? current;
for (final section in sections) {
final key = sectionKeysMap.value.putIfAbsent(
section.category,
() => GlobalKey(),
);
if (key.currentContext?.findRenderObject() == null) continue;
final ro = key.currentContext!.findRenderObject()!;
if (!ro.attached) continue;
try {
final vp = RenderAbstractViewport.of(ro);
final reveal = vp.getOffsetToReveal(ro, 0.0).offset;
if (reveal <= scrollPixels + 80) {
current = section.category;
} else {
break;
}
} catch (_) {
continue;
}
}
if (current != null && current != activeCategoryId.value) {
activeCategoryId.value = current;
}
}
scrollController.addListener(listener);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (active) listener();
});
return () {
active = false;
scrollController.removeListener(listener);
};
}, [sections, scrollController]);
return Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: TextField(
controller: searchController,
focusNode: searchFocus,
decoration: InputDecoration(
hintText: "Search $placeholderText...",
prefixIcon: Icon(Icons.search),
suffixIcon: isSearching
? IconButton(
icon: Icon(Icons.clear),
onPressed: () {
searchController.clear();
searchFocus.requestFocus();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
Expanded(
child: Row(
children: [
if (!isSearching) ...[
SizedBox(
width: 100,
child: ListView.builder(
itemCount: sections.length,
itemBuilder: (context, index) {
final section = sections[index];
final isActive =
activeCategoryId.value == section.category;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
activeCategoryId.value = section.category;
final key = sectionKeysMap.value[section.category];
if (key?.currentContext != null) {
Scrollable.ensureVisible(
key!.currentContext!,
alignment: 0.0,
duration: const Duration(milliseconds: 250),
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
border: Border(
left: BorderSide(
width: 2,
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.transparent,
),
),
color: isActive
? Theme.of(context).colorScheme.primary
.withValues(alpha: 0.08)
: Colors.transparent,
),
child: Text(
section.isRecent ? "Recent" : section.label,
style: TextStyle(
fontSize: 13,
fontWeight: isActive ? FontWeight.w600 : null,
color: isActive
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
);
},
),
),
const VerticalDivider(width: 1),
],
Expanded(
child:
sectionsSnapshot.connectionState == ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: sections.isEmpty
? Center(child: Text("No $placeholderText found"))
: CustomScrollView(
controller: scrollController,
slivers: [
for (final section in sections) ...[
SliverToBoxAdapter(
key: sectionKeysMap.value.putIfAbsent(
section.category,
() => GlobalKey(),
),
child: Container(
padding: const EdgeInsets.only(
left: 12,
top: 12,
bottom: 8,
),
child: Text(
section.isRecent
? "Recently Used"
: section.label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
sliver: SliverGrid(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 44,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
delegate: SliverChildBuilderDelegate((
context,
index,
) {
final item = section.items[index];
return GestureDetector(
onTap: () => onSelect(item.id),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
6,
),
),
child: Text(
item.display,
style: const TextStyle(fontSize: 24),
),
),
);
}, childCount: section.items.length),
),
),
],
const SliverPadding(
padding: EdgeInsets.only(bottom: 24),
),
],
),
),
],
),
),
],
);
}
}
Future<IList<EmoteSection>> fetchEmojiSections(
WidgetRef ref,
String? roomId,
String query,
) async {
final emotes = await ref.watch(EmojiController.provider.future);
final q = query.toLowerCase();
if (q.isEmpty) {
return emotes;
}
return emotes
.map((cat) {
final filtered = cat.items
.where(
(e) =>
e.keywords.any((k) => k.contains(q)) || e.id.contains(query),
)
.toList();
return filtered.isNotEmpty
? EmoteSection(
category: cat.category,
label: cat.label,
icon: cat.icon,
items: filtered,
isRecent: cat.isRecent,
)
: null;
})
.whereType<EmoteSection>()
.toIList();
}
Future<void> showEmotePickerBottomSheet({
required BuildContext context,
String? roomId,
required void Function(String) onSelect,
}) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
useSafeArea: true,
enableDrag: true,
isDismissible: true,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
maxWidth: 600,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => EmotePicker(roomId: roomId, onSelect: onSelect),
);
}

View file

@ -16,7 +16,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:nexus/widgets/emote_picker.dart";
import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/member_list.dart";
@ -239,11 +239,23 @@ class RoomChat extends HookConsumerWidget {
icon: Text(emoji),
),
),
EmojiPickerButton(
context: context,
onPressed: Navigator.of(context).pop,
onSelection: (emoji) =>
notifier.sendReaction(emoji, event).onError(showError),
GestureDetector(
onTap: () {
Navigator.of(context).pop(); // Close popup menu
showEmotePickerBottomSheet(
context: context,
roomId: roomId,
onSelect: (emojiId) async {
await notifier
.sendReaction(emojiId, event)
.onError(showError);
},
);
},
child: Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.emoji_emotions),
),
),
],
),