forked from Nexus/nexus
WIP: New Emoji Picker
This commit is contained in:
parent
49b94a2e34
commit
941e1c0e69
7 changed files with 543 additions and 140 deletions
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
||||||
Subproject commit 1f11743884a64765b1444b250377a09ebcaba93c
|
Subproject commit c06eb1cbda2d49ff199a351e2d55df6ff7ab602b
|
||||||
|
|
@ -1,17 +1,47 @@
|
||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
import "package:emoji_text_field/models/emoji_category.dart";
|
import "package:collection/collection.dart";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter/foundation.dart";
|
import "package:flutter/foundation.dart";
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:http/http.dart";
|
import "package:http/http.dart";
|
||||||
import "package:nexus/models/emoji.dart";
|
import "package:nexus/models/gemoji.dart";
|
||||||
|
|
||||||
typedef EmojiTuple = (IMap<String, EmojiCategory>, IMap<String, List<String>>);
|
class EmoteSection {
|
||||||
|
final String category;
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final List<EmoteItem> items;
|
||||||
|
final bool isRecent;
|
||||||
|
|
||||||
class EmojiController extends AsyncNotifier<EmojiTuple> {
|
const EmoteSection({
|
||||||
|
required this.category,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.items,
|
||||||
|
this.isRecent = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmoteItem {
|
||||||
|
final String id;
|
||||||
|
final String display;
|
||||||
|
final List<String> keywords;
|
||||||
|
final int useCounter;
|
||||||
|
final Image? customImage;
|
||||||
|
|
||||||
|
const EmoteItem({
|
||||||
|
required this.id,
|
||||||
|
required this.display,
|
||||||
|
required this.keywords,
|
||||||
|
required this.useCounter,
|
||||||
|
this.customImage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmojiController extends AsyncNotifier<IList<EmoteSection>> {
|
||||||
@override
|
@override
|
||||||
Future<EmojiTuple> build() async {
|
Future<IList<EmoteSection>> build() async {
|
||||||
final response = await get(
|
final response = await get(
|
||||||
.https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"),
|
.https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"),
|
||||||
);
|
);
|
||||||
|
|
@ -20,69 +50,72 @@ class EmojiController extends AsyncNotifier<EmojiTuple> {
|
||||||
throw Exception("Failed to load emoji data");
|
throw Exception("Failed to load emoji data");
|
||||||
}
|
}
|
||||||
|
|
||||||
return compute(_parseEmojiJson, response.body);
|
return compute(parseEmojiJson, response.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
static EmojiTuple _parseEmojiJson(String body) {
|
static IList<EmoteSection> parseEmojiJson(String body) {
|
||||||
final data = json.decode(body) as List;
|
final data = json.decode(body) as List;
|
||||||
final entries = data
|
final allEntries = data
|
||||||
.cast<Map<String, dynamic>>()
|
.cast<Map<String, dynamic>>()
|
||||||
.map(Emoji.fromJson)
|
.map(Gemoji.fromJson)
|
||||||
.toIList();
|
.toIList();
|
||||||
|
|
||||||
final categoryMap = entries.fold<IMap<String, IList<String>>>(
|
final groupedByCategory = groupBy(allEntries, (g) => g.category);
|
||||||
|
|
||||||
|
final result = groupedByCategory.entries.fold<IList<EmoteSection>>(
|
||||||
.new(),
|
.new(),
|
||||||
(acc, entry) => acc.update(
|
(resultList, entry) => resultList.add(
|
||||||
entry.category,
|
EmoteSection(
|
||||||
(list) => list.add(entry.emoji),
|
category: entry.key,
|
||||||
ifAbsent: () => .new([entry.emoji]),
|
label: entry.key,
|
||||||
|
icon: getIconForCategory(entry.key),
|
||||||
|
items: entry.value
|
||||||
|
.map(
|
||||||
|
(gemoji) => EmoteItem(
|
||||||
|
id: gemoji.emoji,
|
||||||
|
display: gemoji.emoji,
|
||||||
|
keywords: [
|
||||||
|
...gemoji.tags,
|
||||||
|
...gemoji.aliases,
|
||||||
|
...gemoji.description.split(" "),
|
||||||
|
],
|
||||||
|
useCounter: 0,
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.toList(),
|
||||||
final keywordMap = entries.fold<IMap<String, IList<String>>>(
|
isRecent: false,
|
||||||
.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);
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider<EmojiController, EmojiTuple>(
|
static final provider =
|
||||||
|
AsyncNotifierProvider<EmojiController, IList<EmoteSection>>(
|
||||||
EmojiController.new,
|
EmojiController.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static IconData getIconForCategory(String category) {
|
||||||
|
switch (category.toLowerCase()) {
|
||||||
|
case "smileys & emotion":
|
||||||
|
return Icons.sentiment_very_satisfied;
|
||||||
|
case "people & body":
|
||||||
|
return Icons.face;
|
||||||
|
case "food & drink":
|
||||||
|
return Icons.restaurant;
|
||||||
|
case "travel & places":
|
||||||
|
return Icons.flag;
|
||||||
|
case "activities":
|
||||||
|
return Icons.sports_soccer;
|
||||||
|
case "objects":
|
||||||
|
return Icons.lightbulb;
|
||||||
|
case "symbols":
|
||||||
|
return Icons.circle;
|
||||||
|
case "flags":
|
||||||
|
return Icons.flag;
|
||||||
|
default:
|
||||||
|
return Icons.emoji_emotions_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
part "emoji.freezed.dart";
|
part "gemoji.freezed.dart";
|
||||||
part "emoji.g.dart";
|
part "gemoji.g.dart";
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class Emoji with _$Emoji {
|
abstract class Gemoji with _$Gemoji {
|
||||||
const factory Emoji({
|
const factory Gemoji({
|
||||||
required String emoji,
|
required String emoji,
|
||||||
required String category,
|
required String category,
|
||||||
required IList<String> aliases,
|
required IList<String> aliases,
|
||||||
required String description,
|
required String description,
|
||||||
required IList<String> tags,
|
required IList<String> tags,
|
||||||
}) = _Emoji;
|
}) = _Gemoji;
|
||||||
|
|
||||||
factory Emoji.fromJson(Map<String, Object?> json) => _$EmojiFromJson(json);
|
factory Gemoji.fromJson(Map<String, Object?> json) => _$GemojiFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
@ -14,8 +14,8 @@ import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/composer/mention_overlay.dart";
|
import "package:nexus/widgets/composer/mention_overlay.dart";
|
||||||
import "package:nexus/widgets/composer/relation_preview.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/main.dart";
|
||||||
|
import "package:nexus/widgets/emote_picker.dart";
|
||||||
|
|
||||||
class Composer extends HookConsumerWidget {
|
class Composer extends HookConsumerWidget {
|
||||||
final String roomId;
|
final String roomId;
|
||||||
|
|
@ -123,10 +123,35 @@ class Composer extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
? [
|
? [
|
||||||
EmojiPickerButton(
|
IconButton(
|
||||||
|
icon: Icon(Icons.emoji_emotions),
|
||||||
|
tooltip: "Insert emoji",
|
||||||
|
onPressed: () {
|
||||||
|
showEmotePickerBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
onSelection: (_) => node?.requestFocus(),
|
roomId: roomId,
|
||||||
controller: controller.value,
|
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(
|
PopupMenuButton(
|
||||||
tooltip: "Add media",
|
tooltip: "Add media",
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
399
lib/widgets/emote_picker.dart
Normal file
399
lib/widgets/emote_picker.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ import "package:nexus/models/content/message.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/composer/composer.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/pinned_events_drawer.dart";
|
||||||
import "package:nexus/widgets/renderers/event.dart";
|
import "package:nexus/widgets/renderers/event.dart";
|
||||||
import "package:nexus/widgets/member_list.dart";
|
import "package:nexus/widgets/member_list.dart";
|
||||||
|
|
@ -239,11 +239,23 @@ class RoomChat extends HookConsumerWidget {
|
||||||
icon: Text(emoji),
|
icon: Text(emoji),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
EmojiPickerButton(
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop(); // Close popup menu
|
||||||
|
showEmotePickerBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
onPressed: Navigator.of(context).pop,
|
roomId: roomId,
|
||||||
onSelection: (emoji) =>
|
onSelect: (emojiId) async {
|
||||||
notifier.sendReaction(emoji, event).onError(showError),
|
await notifier
|
||||||
|
.sendReaction(emojiId, event)
|
||||||
|
.onError(showError);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: Icon(Icons.emoji_emotions),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue