diff --git a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart index 4150c701..54bf5c64 100644 --- a/lib/screens/channel/chat/widgets/chat_bottom_bar.dart +++ b/lib/screens/channel/chat/widgets/chat_bottom_bar.dart @@ -1,3 +1,4 @@ +import 'package:extended_text_field/extended_text_field.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -8,6 +9,7 @@ import 'package:frosty/screens/channel/chat/stores/chat_store.dart'; import 'package:frosty/utils/context_extensions.dart'; import 'package:frosty/utils/modal_bottom_sheet.dart'; import 'package:frosty/widgets/blurred_container.dart'; +import 'package:frosty/widgets/chat_input/emote_text_span_builder.dart'; import 'package:frosty/widgets/frosty_cached_network_image.dart'; class ChatBottomBar extends StatelessWidget { @@ -256,13 +258,20 @@ class ChatBottomBar extends StatelessWidget { ); } : null, - child: TextField( + child: ExtendedTextField( textInputAction: TextInputAction.send, focusNode: chatStore.textFieldFocusNode, minLines: 1, maxLines: 3, // Disable text field when sending message or when not logged in enabled: !isDisabled, + specialTextSpanBuilder: EmoteTextSpanBuilder( + emoteToObject: chatStore.assetsStore.emoteToObject, + userEmoteToObject: + chatStore.assetsStore.userEmoteToObject, + emoteSize: + chatStore.settings.emoteScale * defaultEmoteSize, + ), decoration: InputDecoration( prefixIcon: chatStore.settings.emoteMenuButtonOnLeft diff --git a/lib/widgets/chat_input/emote_text_span_builder.dart b/lib/widgets/chat_input/emote_text_span_builder.dart new file mode 100644 index 00000000..785140ff --- /dev/null +++ b/lib/widgets/chat_input/emote_text_span_builder.dart @@ -0,0 +1,122 @@ +import 'package:extended_text_field/extended_text_field.dart'; +import 'package:flutter/material.dart'; +import 'package:frosty/models/emotes.dart'; +import 'package:frosty/widgets/frosty_cached_network_image.dart'; + +/// A [SpecialTextSpanBuilder] that renders emote names as inline images. +/// +/// This builder parses input text word-by-word and converts recognized emote +/// names into [ExtendedWidgetSpan] widgets displaying the emote image. +/// Unrecognized words remain as regular text. +class EmoteTextSpanBuilder extends SpecialTextSpanBuilder { + /// Map of emote names to their [Emote] objects (global + channel emotes). + final Map emoteToObject; + + /// Map of user-owned emotes (subscriber, bits, unlocked). + final Map userEmoteToObject; + + /// The height to render emotes at. + final double emoteSize; + + EmoteTextSpanBuilder({ + required this.emoteToObject, + required this.userEmoteToObject, + required this.emoteSize, + }); + + @override + TextSpan build( + String data, { + TextStyle? textStyle, + SpecialTextGestureTapCallback? onTap, + }) { + if (data.isEmpty) { + return TextSpan(text: '', style: textStyle); + } + + final spans = []; + var currentIndex = 0; + + // Split by spaces but preserve space positions + final parts = _splitPreservingSpaces(data); + + for (final part in parts) { + if (part == ' ') { + // Add space as regular text + spans.add(TextSpan(text: ' ', style: textStyle)); + currentIndex += 1; + } else if (part.isNotEmpty) { + // Check if this word is an emote - render immediately on exact match + final emote = emoteToObject[part] ?? userEmoteToObject[part]; + + if (emote != null) { + spans.add(_createEmoteSpan(emote, part, currentIndex)); + } else { + spans.add(TextSpan(text: part, style: textStyle)); + } + currentIndex += part.length; + } + } + + return TextSpan(children: spans, style: textStyle); + } + + /// Splits text into words and spaces, preserving the spaces as separate elements. + List _splitPreservingSpaces(String text) { + final result = []; + final buffer = StringBuffer(); + + for (var i = 0; i < text.length; i++) { + final char = text[i]; + if (char == ' ') { + if (buffer.isNotEmpty) { + result.add(buffer.toString()); + buffer.clear(); + } + result.add(' '); + } else { + buffer.write(char); + } + } + + if (buffer.isNotEmpty) { + result.add(buffer.toString()); + } + + return result; + } + + /// Creates an [ExtendedWidgetSpan] for the given emote. + ExtendedWidgetSpan _createEmoteSpan(Emote emote, String text, int start) { + // Calculate dimensions - use emote's native size if available, + // otherwise use the configured emoteSize + final height = emote.height?.toDouble() ?? emoteSize; + final width = emote.width?.toDouble(); + + return ExtendedWidgetSpan( + actualText: text, + start: start, + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: FrostyCachedNetworkImage( + imageUrl: emote.url, + height: height, + width: width, + useFade: false, + ), + ), + ); + } + + @override + SpecialText? createSpecialText( + String flag, { + TextStyle? textStyle, + SpecialTextGestureTapCallback? onTap, + int? index, + }) { + // We handle all parsing in the build method, so this returns null + return null; + } +} diff --git a/pubspec.lock b/pubspec.lock index ec8c083a..19197629 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -305,6 +305,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + extended_text_field: + dependency: "direct main" + description: + name: extended_text_field + sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d" + url: "https://pub.dev" + source: hosted + version: "16.0.2" + extended_text_library: + dependency: transitive + description: + name: extended_text_library + sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba" + url: "https://pub.dev" + source: hosted + version: "12.0.1" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 71604401..661a57d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,7 @@ dependencies: path_provider: ^2.1.5 dio: ^5.9.0 app_links: ^7.0.0 + extended_text_field: ^16.0.2 dev_dependencies: flutter_test: