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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.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/context_extensions.dart';
|
||||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||||
import 'package:frosty/widgets/blurred_container.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';
|
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||||
|
|
||||||
class ChatBottomBar extends StatelessWidget {
|
class ChatBottomBar extends StatelessWidget {
|
||||||
|
|
@ -256,13 +258,20 @@ class ChatBottomBar extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
child: TextField(
|
child: ExtendedTextField(
|
||||||
textInputAction: TextInputAction.send,
|
textInputAction: TextInputAction.send,
|
||||||
focusNode: chatStore.textFieldFocusNode,
|
focusNode: chatStore.textFieldFocusNode,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
// Disable text field when sending message or when not logged in
|
// Disable text field when sending message or when not logged in
|
||||||
enabled: !isDisabled,
|
enabled: !isDisabled,
|
||||||
|
specialTextSpanBuilder: EmoteTextSpanBuilder(
|
||||||
|
emoteToObject: chatStore.assetsStore.emoteToObject,
|
||||||
|
userEmoteToObject:
|
||||||
|
chatStore.assetsStore.userEmoteToObject,
|
||||||
|
emoteSize:
|
||||||
|
chatStore.settings.emoteScale * defaultEmoteSize,
|
||||||
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon:
|
prefixIcon:
|
||||||
chatStore.settings.emoteMenuButtonOnLeft
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ dependencies:
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
dio: ^5.9.0
|
dio: ^5.9.0
|
||||||
app_links: ^7.0.0
|
app_links: ^7.0.0
|
||||||
|
extended_text_field: ^16.0.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue