diff --git a/lib/components/input_bar.dart b/lib/components/input_bar.dart new file mode 100644 index 0000000..6337cc1 --- /dev/null +++ b/lib/components/input_bar.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:flutter_advanced_networkimage/provider.dart'; + +class InputBar extends StatelessWidget { + final Room room; + final int minLines; + final int maxLines; + final TextInputType keyboardType; + final ValueChanged onSubmitted; + final FocusNode focusNode; + final TextEditingController controller; + final InputDecoration decoration; + final ValueChanged onChanged; + + InputBar({ + this.room, + this.minLines, + this.maxLines, + this.keyboardType, + this.onSubmitted, + this.focusNode, + this.controller, + this.decoration, + this.onChanged, + }); + + Map> getEmotePacks() { + final emotePacks = >{}; + final addEmotePack = (String packName, Map content) { + emotePacks[packName] = {}; + content.forEach((key, value) { + if (key is String && value is String && value.startsWith('mxc://')) { + emotePacks[packName][key] = value; + } + }); + }; + final roomEmotes = room.getState('im.ponies.room_emotes'); + final userEmotes = room.client.accountData['im.ponies.user_emotes']; + if (roomEmotes != null && roomEmotes.content['short'] is Map) { + addEmotePack('room', roomEmotes.content['short']); + } + if (userEmotes != null && userEmotes.content['short'] is Map) { + addEmotePack('user', userEmotes.content['short']); + } + return emotePacks; + } + + List> getSuggestions(String text) { + if (controller.selection.baseOffset != controller.selection.extentOffset || controller.selection.baseOffset < 0) { + return []; // no entries if there is selected text + } + final searchText = controller.text.substring(0, controller.selection.baseOffset); + final ret = >[]; + final emojiMatch = RegExp(r'(?:\s|^):(?:([-\w]+)~)?([-\w]+)$').firstMatch(searchText); + if (emojiMatch != null) { + final packSearch = emojiMatch[1]; + final emoteSearch = emojiMatch[2].toLowerCase(); + var results = 0; + final emotePacks = getEmotePacks(); + if (packSearch == null || packSearch.isEmpty) { + for (final pack in emotePacks.entries) { + for (final emote in pack.value.entries) { + if (emote.key.toLowerCase().contains(emoteSearch)) { + ret.add({ + 'type': 'emote', + 'name': emote.key, + 'pack': pack.key, + 'mxc': emote.value, + }); + results++; + } + if (results > 10) { + break; + } + } + if (results > 10) { + break; + } + } + } else if (emotePacks[packSearch] != null) { + for (final emote in emotePacks[packSearch].entries) { + if (emote.key.toLowerCase().contains(emoteSearch)) { + ret.add({ + 'type': 'emote', + 'name': emote.key, + 'pack': packSearch, + 'mxc': emote.value, + }); + results++; + } + if (results > 10) { + break; + } + } + } + } + return ret; + } + + Widget buildSuggestion(BuildContext context, Map suggestion) { + if (suggestion['type'] == 'emote') { + final size = 30.0; + final ratio = MediaQuery.of(context).devicePixelRatio; + final url = Uri.parse(suggestion['mxc'] ?? '')?.getThumbnail( + room.client, + width: size * ratio, + height: size * ratio, + method: ThumbnailMethod.scale, + ); + return Container( + padding: EdgeInsets.all(4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image( + image: kIsWeb + ? NetworkImage(url) + : AdvancedNetworkImage(url, useDiskCache: true), + width: size, + height: size, + ), + SizedBox(width: 6), + Text(suggestion['name']), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: Opacity( + opacity: 0.5, + child: Text(suggestion['pack']), + ), + ), + ), + ], + ), + ); + } + return Container(); + } + + void insertSuggestion(BuildContext context, Map suggestion) { + if (suggestion['type'] == 'emote') { + var isUnique = true; + final insertEmote = suggestion['name']; + final insertPack = suggestion['pack']; + final emotePacks = getEmotePacks(); + for (final pack in emotePacks.entries) { + if (pack.key == insertPack) { + continue; + } + for (final emote in pack.value.entries) { + if (emote.key == insertEmote) { + isUnique = false; + break; + } + } + if (!isUnique) { + break; + } + } + final insertText = isUnique ? insertEmote : ':${insertPack}~${insertEmote.substring(1)}'; + final replaceText = controller.text.substring(0, controller.selection.baseOffset); + final afterText = replaceText == controller.text ? '' : controller.text.substring(controller.selection.baseOffset + 1); + final startText = replaceText.replaceAllMapped( + RegExp(r'(\s|^)(:(?:[-\w]+~)?[-\w]+)$'), + (Match m) => '${m[1]}${insertText} ', + ); + controller.text = startText + afterText; + if (startText == insertText + ' ') { + // stupid fix for now + FocusScope.of(context).requestFocus(FocusNode()); + Future.delayed(Duration(milliseconds: 1)).then((res) { + focusNode.requestFocus(); + controller.selection = TextSelection( + baseOffset: startText.length, + extentOffset: startText.length, + ); + }); + } else { + controller.selection = TextSelection( + baseOffset: startText.length, + extentOffset: startText.length, + ); + } + } + } + + @override + Widget build(BuildContext context) { + return TypeAheadField>( + direction: AxisDirection.up, + hideOnEmpty: true, + hideOnLoading: true, + keepSuggestionsOnSuggestionSelected: true, + debounceDuration: Duration(milliseconds: 50), // show suggestions after 50ms idle time (default is 300) + textFieldConfiguration: TextFieldConfiguration( + minLines: minLines, + maxLines: maxLines, + keyboardType: keyboardType, + onSubmitted: (text) { // fix for library for now + onSubmitted(text); + }, + focusNode: focusNode, + controller: controller, + decoration: decoration, + onChanged: (text) { + onChanged(text); + }, + ), + suggestionsCallback: getSuggestions, + itemBuilder: buildSuggestion, + onSuggestionSelected: (Map suggestion) => insertSuggestion(context, suggestion), + errorBuilder: (BuildContext context, Object error) => Container(), + loadingBuilder: (BuildContext context) => Container(), // fix loading briefly flickering a dark box + noItemsFoundBuilder: (BuildContext context) => Container(), // fix loading briefly showing no suggestions + ); + } +} diff --git a/lib/views/chat.dart b/lib/views/chat.dart index 11bc6cc..47f7c90 100644 --- a/lib/views/chat.dart +++ b/lib/views/chat.dart @@ -20,6 +20,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:pedantic/pedantic.dart'; import 'chat_list.dart'; +import '../components/input_bar.dart'; class ChatView extends StatelessWidget { final String id; @@ -693,7 +694,8 @@ class _ChatState extends State<_Chat> { child: Padding( padding: const EdgeInsets.symmetric( vertical: 4.0), - child: TextField( + child: InputBar( + room: room, minLines: 1, maxLines: kIsWeb ? 1 : 8, keyboardType: kIsWeb diff --git a/pubspec.lock b/pubspec.lock index d02aa69..9b4c606 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -171,6 +171,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.4" + flutter_keyboard_visibility: + dependency: transitive + description: + name: flutter_keyboard_visibility + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -244,6 +251,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_typeahead: + dependency: "direct main" + description: + name: flutter_typeahead + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.1" flutter_web_plugins: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 61b7d6c..3732866 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: flutter_matrix_html: ^0.0.7 moor: ^3.0.2 random_string: ^2.0.1 + flutter_typeahead: ^1.8.1 intl: ^0.16.0 intl_translation: ^0.17.9