add inline emote support in chat text field
This commit is contained in:
parent
359509ec52
commit
23fee7bd4e
4 changed files with 149 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
122
lib/widgets/chat_input/emote_text_span_builder.dart
Normal file
122
lib/widgets/chat_input/emote_text_span_builder.dart
Normal file
|
|
@ -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<String, Emote> emoteToObject;
|
||||
|
||||
/// Map of user-owned emotes (subscriber, bits, unlocked).
|
||||
final Map<String, Emote> 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 = <InlineSpan>[];
|
||||
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<String> _splitPreservingSpaces(String text) {
|
||||
final result = <String>[];
|
||||
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;
|
||||
}
|
||||
}
|
||||
16
pubspec.lock
16
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue