diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 7f4861099..7b4ab637a 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,9 +1,14 @@ ## Upcoming +✅ Added + +- Added several new widgets to enhance the AI assistant features. + - `StreamingMessageView` to show AI assistant messages with streaming animation. + - `AITypingIndicatorView` to show AI typing indicator. + 🐞 Fixed -- [[#2030]](https://github.com/GetStream/stream-chat-flutter/issues/2030) Fixed `video_thumbnail` - Namespace not specified. +- [[#2030]](https://github.com/GetStream/stream-chat-flutter/issues/2030) Fixed `video_thumbnail` Namespace not specified. ## 8.2.0 diff --git a/packages/stream_chat_flutter/lib/src/ai_assistant/ai_typing_indicator_view.dart b/packages/stream_chat_flutter/lib/src/ai_assistant/ai_typing_indicator_view.dart new file mode 100644 index 000000000..d1cec0250 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/ai_assistant/ai_typing_indicator_view.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; + +/// {@template aiTypingIndicatorView} +/// A widget that displays a typing indicator for the AI. +/// +/// This widget is used to indicate the various states of the AI such as +/// [AI_STATE_THINKING], [AI_STATE_CHECKING_SOURCES] etc. +/// +/// The widget displays a text and a series of animated dots. +/// +/// ```dart +/// AITypingIndicatorView( +/// text: 'AI is thinking', +/// ); +/// ``` +/// +/// see also: +/// - [AnimatedDots] which is used to display the animated dots. +/// {@endtemplate} +class AITypingIndicatorView extends StatelessWidget { + /// {@macro aiTypingIndicatorView} + const AITypingIndicatorView({ + super.key, + required this.text, + this.textStyle, + this.dotColor, + this.dotCount = 3, + this.dotSize = 8, + }); + + /// The text to display in the widget. + /// + /// Typically this is the state of the AI such as "AI is thinking", + final String text; + + /// The style to use for the text. + /// + /// If not provided, the default text style is used. + final TextStyle? textStyle; + + /// The color of the animated dots displayed next to the text. + /// + /// If not provided, the color of the [textStyle] is used if available or + /// [Colors.black] is used. + final Color? dotColor; + + /// The number of animated dots to display next to the text. + /// + /// Defaults to 3. + final int dotCount; + + /// The size of the animated dots displayed next to the text. + /// + /// Defaults to 8. + final double dotSize; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(text, style: textStyle), + const SizedBox(width: 8), + AnimatedDots( + size: dotSize, + count: dotCount, + color: dotColor ?? textStyle?.color ?? Colors.black, + ), + ], + ); + } +} + +/// {@template animatedDots} +/// A widget that displays a series of animated dots. +/// +/// The dots are animated to scale up and down in size and fade in and out in +/// opacity. +/// +/// The widget is typically used to indicate that someone is typing. +/// {@endtemplate} +class AnimatedDots extends StatelessWidget { + /// {@macro animatedDots} + const AnimatedDots({ + super.key, + this.count = 3, + this.size = 8, + this.spacing = 4, + this.color = Colors.black, + }); + + /// The number of dots to display. + /// + /// Defaults to 3. + final int count; + + /// The size of each dot. + /// + /// Defaults to 8. + final double size; + + /// The spacing between each dot. + /// + /// Defaults to 4. + final double spacing; + + /// The color of the dots. + /// + /// Defaults to [Colors.black]. + final Color color; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...List.generate( + count, + (index) => _AnimatedDot( + key: ValueKey(index), + index: index, + size: size, + color: color, + ), + ), + ].insertBetween(const SizedBox(width: 4)), + ); + } +} + +class _AnimatedDot extends StatefulWidget { + const _AnimatedDot({ + super.key, + required this.index, + this.size = 8, + this.color = Colors.black, + }); + + final int index; + final double size; + final Color color; + + @override + State<_AnimatedDot> createState() => _AnimatedDotState(); +} + +class _AnimatedDotState extends State<_AnimatedDot> + with SingleTickerProviderStateMixin<_AnimatedDot> { + late final AnimationController _repeatingController; + + @override + void initState() { + super.initState(); + _repeatingController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + )..addStatusListener( + (status) { + if (status == AnimationStatus.completed) { + if (mounted) _repeatingController.reverse(); + } else if (status == AnimationStatus.dismissed) { + if (mounted) _repeatingController.forward(); + } + }, + ); + + Future.delayed( + Duration(milliseconds: 200 * widget.index), + () { + if (mounted) _repeatingController.forward(); + }, + ); + } + + @override + void dispose() { + _repeatingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final animation = CurvedAnimation( + parent: _repeatingController, + curve: Curves.easeInOut, + ); + + return ScaleTransition( + scale: Tween(begin: 0.5, end: 1).animate(animation), + child: FadeTransition( + opacity: Tween(begin: 0.3, end: 1).animate(animation), + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/ai_assistant/stream_typewriter_builder.dart b/packages/stream_chat_flutter/lib/src/ai_assistant/stream_typewriter_builder.dart new file mode 100644 index 000000000..e004784bf --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/ai_assistant/stream_typewriter_builder.dart @@ -0,0 +1,248 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/widgets.dart'; + +/// {@template typewriterState} +/// The current typing state of a typewriter. +/// {@endtemplate} +enum TypewriterState { + /// The typewriter is not typing. + idle, + + /// The typewriter is currently typing. + typing, + + /// The typewriter is paused at the current char index. + paused, + + /// The typewriter has stopped typing and has reset the current char index. + stopped, +} + +/// {@template typewriterValue} +/// A value class that holds the current text and typing state of a typewriter. +/// +/// The [text] field holds the current text that has been typed out. The [state] +/// field holds the current typing state of the typewriter. +/// {@endtemplate} +class TypewriterValue { + /// {@macro typewriterValue} + const TypewriterValue({ + this.text = '', + this.state = TypewriterState.idle, + }); + + /// The current text that has been typed out. + final String text; + + /// The current typing state of the typewriter. + final TypewriterState state; + + /// Creates a copy of this [TypewriterValue] with the given fields replaced + /// by the new values. + TypewriterValue copyWith({ + String? text, + TypewriterState? state, + }) { + return TypewriterValue( + text: text ?? this.text, + state: state ?? this.state, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TypewriterValue && + other.text == text && + other.state == state; + } + + @override + int get hashCode => text.hashCode ^ state.hashCode; +} + +/// {@template typewriterController} +/// A controller for a [StreamTypewriterBuilder]. It allows you to control the +/// typing state of the typewriter. You can start, pause, and stop typing the +/// target text. +/// +/// To use a [TypewriterController], simply create one and pass it to a +/// [StreamTypewriterBuilder]. The builder will listen to the controller and +/// rebuild whenever the value changes. You can then control the typing state +/// by calling [startTyping], [pauseTyping], and [stopTyping] on the controller. +/// +/// ```dart +/// final controller = TypewriterController(text: 'Hello, World!'); +/// +/// @override +/// Widget build(BuildContext context) { +/// return StreamTypewriterBuilder( +/// controller: controller, +/// builder: (context, value, child) { +/// return Text(value.text); +/// }, +/// ); +/// } +/// +/// // Start typing the target text. +/// controller.startTyping(); +/// +/// // Pause typing at the current char index. +/// controller.pauseTyping(); +/// +/// // Stop typing and reset the current char index. +/// controller.stopTyping(); +/// +/// // Update the target text. +/// controller.updateText('Hello, Flutter!'); +/// ``` +/// {@endtemplate} +class TypewriterController extends ValueNotifier { + /// {@macro typewriterController} + TypewriterController({ + String text = '', + this.typingSpeed = const Duration(milliseconds: 10), + }) : super(TypewriterValue(text: text)) { + // Set the target text and the current char index. + _targetText = value.text.characters; + _currentCharIndex = _targetText.length - 1; + } + + /// The speed at which the text should be typed out. + /// + /// Defaults to `10 milliseconds` per character. + final Duration typingSpeed; + + Timer? _timer; + + late int _currentCharIndex; + late Characters _targetText; + + /// Cancels the current typing timer and displays the target text immediately. + /// + /// This is useful when you want to display the target text immediately + /// without typing it out. + set text(String newText) { + _timer?.cancel(); + _targetText = newText.characters; + _currentCharIndex = _targetText.length; + value = value.copyWith(text: newText, state: TypewriterState.idle); + } + + /// Updates the target text to [newText]. + /// + /// If the controller is currently typing, the new text will be typed out + /// automatically. If it is not typing, the new text will be typed out only + /// if [autoStart] is true. + void updateText(String newText, {bool autoStart = true}) { + // Update the target text. + _targetText = newText.characters; + + // Start typing the new text if autoStart is true. + // + // This is only needed if the controller is currently not typing. If it is + // typing, the new text will be typed out automatically. + if (autoStart) startTyping(); + } + + /// Starts typing the target text. + /// + /// If the target text is already being typed out or is already all typed out, + /// this method does nothing. + /// + /// To pause or stop typing, call [pauseTyping] or [stopTyping] respectively. + void startTyping() { + // If already typing, return. + if (value.state == TypewriterState.typing) return; + + // If target text is already all typed out, return. + if (_currentCharIndex >= _targetText.length) return; + + value = value.copyWith(state: TypewriterState.typing); + + _timer = Timer.periodic(typingSpeed, (timer) { + if (_currentCharIndex < _targetText.length) { + _currentCharIndex = min(_currentCharIndex + 1, _targetText.length); + final newDisplayedText = _targetText.take(_currentCharIndex).string; + value = value.copyWith(text: newDisplayedText); + } else { + timer.cancel(); + value = value.copyWith(state: TypewriterState.idle); + } + }); + } + + /// Pauses typing at the current char index. + /// + /// To resume typing, call [startTyping]. + void pauseTyping() { + if (value.state != TypewriterState.typing) return; + + _timer?.cancel(); + value = value.copyWith(state: TypewriterState.paused); + } + + /// Stops typing and resets the current char index. + /// + /// To start typing again, call [startTyping]. + void stopTyping() { + _timer?.cancel(); + value = value.copyWith(state: TypewriterState.stopped); + + _currentCharIndex = 0; + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} + +/// {@template typewriterWidgetBuilder} +/// A widget builder for a [StreamTypewriterBuilder]. It allows you to build a +/// widget depending on the [TypewriterValue]'s value. +/// {@endtemplate} +typedef TypewriterWidgetBuilder = Widget Function( + BuildContext context, + TypewriterValue value, + Widget? child, +); + +/// {@template streamTypewriterBuilder} +/// A widget that listens to a [TypewriterController] and rebuilds whenever the +/// value changes. It allows you to build a widget depending on the controller's +/// value. +/// {@endtemplate} +class StreamTypewriterBuilder extends StatelessWidget { + /// {@macro streamTypewriterBuilder} + const StreamTypewriterBuilder({ + super.key, + required this.controller, + required this.builder, + this.child, + }); + + /// The TypewriterController to listen to. + final TypewriterController controller; + + /// The builder to build the widget depending on the controller's value. + final TypewriterWidgetBuilder builder; + + /// The child widget to pass to the builder. + /// + /// This is typically used to pass a widget that does not depend on the + /// controller's value. + final Widget? child; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: builder, + child: child, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart b/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart new file mode 100644 index 000000000..4422b5d86 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:stream_chat_flutter/src/ai_assistant/stream_typewriter_builder.dart'; +import 'package:stream_chat_flutter/src/misc/markdown_message.dart'; +import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; +import 'package:stream_chat_flutter/src/utils/helpers.dart'; + +/// {@template streamingMessageView} +/// A widget that displays a message in a streaming fashion. The message is +/// displayed as if it is being typed out by a typewriter. +/// {@endtemplate} +class StreamingMessageView extends StatefulWidget { + /// {@macro streamingMessageView} + const StreamingMessageView({ + super.key, + required this.text, + this.onTapLink, + this.typingSpeed = const Duration(milliseconds: 10), + this.onTypewriterStateChanged, + }); + + /// The text to display in the widget. + final String text; + + /// The speed at which the text is typed out. + /// + /// Defaults to 10 milliseconds per character. + final Duration typingSpeed; + + /// Called when the user taps a link in the message. + final MarkdownTapLinkCallback? onTapLink; + + /// A callback that is called whenever the typewriter state changes. + final ValueChanged? onTypewriterStateChanged; + + @override + State createState() => _StreamingMessageViewState(); +} + +class _StreamingMessageViewState extends State { + late String _displayText; + late final TypewriterController _controller; + + void _onTypewriterValueChanged() { + final value = _controller.value; + widget.onTypewriterStateChanged?.call(value.state); + setState(() => _displayText = value.text); + } + + @override + void initState() { + super.initState(); + _controller = TypewriterController( + text: widget.text, + typingSpeed: widget.typingSpeed, + )..addListener(_onTypewriterValueChanged); + + _displayText = _controller.value.text; + } + + @override + void didUpdateWidget(covariant StreamingMessageView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.text != oldWidget.text) { + _controller.updateText(widget.text); + } + } + + @override + void dispose() { + _controller + ..removeListener(_onTypewriterValueChanged) + ..dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamMarkdownMessage( + data: _displayText, + selectable: isDesktopDeviceOrWeb, + onTapLink: switch (widget.onTapLink) { + final onTapLink? => onTapLink, + _ => (String link, String? href, String title) { + if (href != null) launchURL(context, href); + }, + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_text.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_text.dart index 31a3378a3..a092f34f7 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_text.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_text.dart @@ -1,6 +1,5 @@ import 'package:collection/collection.dart' show IterableExtension; import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamMessageText} @@ -42,12 +41,11 @@ class StreamMessageText extends StatelessWidget { .text ?.replaceAll('\n', '\n\n') .trim(); - final themeData = Theme.of(context); - return MarkdownBody( + + return StreamMarkdownMessage( data: messageText ?? '', + messageTheme: messageTheme, selectable: isDesktopDeviceOrWeb, - onTapText: () {}, - onSelectionChanged: (val, selection, cause) {}, onTapLink: ( String link, String? href, @@ -69,20 +67,6 @@ class StreamMessageText extends StatelessWidget { } } }, - styleSheet: MarkdownStyleSheet.fromTheme( - themeData.copyWith( - textTheme: themeData.textTheme.apply( - bodyColor: messageTheme.messageTextStyle?.color, - decoration: messageTheme.messageTextStyle?.decoration, - decorationColor: messageTheme.messageTextStyle?.decorationColor, - decorationStyle: messageTheme.messageTextStyle?.decorationStyle, - fontFamily: messageTheme.messageTextStyle?.fontFamily, - ), - ), - ).copyWith( - a: messageTheme.messageLinksStyle, - p: messageTheme.messageTextStyle, - ), ); }, ); diff --git a/packages/stream_chat_flutter/lib/src/misc/markdown_message.dart b/packages/stream_chat_flutter/lib/src/misc/markdown_message.dart new file mode 100644 index 000000000..9b0926ed0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/markdown_message.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:stream_chat_flutter/src/ai_assistant/streaming_message_view.dart'; +import 'package:stream_chat_flutter/src/theme/message_theme.dart'; + +import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; + +/// {@template streamMarkdownMessage} +/// A widget that displays a markdown message. This widget uses the markdown +/// package to parse the markdown data and display it. +/// +/// This widget is used by [StreamMessageText] and [StreamingMessageView] to +/// display the message text. +/// {@endtemplate} +class StreamMarkdownMessage extends StatelessWidget { + /// {@macro streamMarkdownMessage} + const StreamMarkdownMessage({ + super.key, + required this.data, + this.selectable, + this.onTapLink, + this.messageTheme, + this.styleSheet, + this.syntaxHighlighter, + this.builders = const {}, + this.paddingBuilders = const {}, + }); + + /// The markdown data to display. + final String data; + + /// Whether the text is selectable. + final bool? selectable; + + /// Called when the user taps a link. + final MarkdownTapLinkCallback? onTapLink; + + /// The theme to apply to the message text. + final StreamMessageThemeData? messageTheme; + + /// Optional style sheet to customize the markdown output. + /// + /// When provided, it will be merged with the default one. + final MarkdownStyleSheet? styleSheet; + + /// The syntax highlighter used to color text in `pre` elements. + /// + /// If null, the [MarkdownStyleSheet.code] style is used for `pre` elements. + final SyntaxHighlighter? syntaxHighlighter; + + /// Render certain tags, usually used with [extensionSet] + /// + /// For example, we will add support for `sub` tag: + /// + /// ```dart + /// builders: { + /// 'sub': SubscriptBuilder(), + /// } + /// ``` + /// + /// The `SubscriptBuilder` is a subclass of [MarkdownElementBuilder]. + final Map builders; + + /// Add padding for different tags (use only for block elements and img) + /// + /// For example, we will add padding for `img` tag: + /// + /// ```dart + /// paddingBuilders: { + /// 'img': ImgPaddingBuilder(), + /// } + /// ``` + /// + /// The `ImgPaddingBuilder` is a subclass of [MarkdownPaddingBuilder]. + final Map paddingBuilders; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + + return MarkdownBody( + data: data, + selectable: selectable ?? isDesktopDeviceOrWeb, + onTapText: () {}, + onSelectionChanged: (val, selection, cause) {}, + onTapLink: onTapLink, + syntaxHighlighter: syntaxHighlighter, + builders: builders, + paddingBuilders: paddingBuilders, + styleSheet: MarkdownStyleSheet.fromTheme( + themeData.copyWith( + textTheme: themeData.textTheme.apply( + bodyColor: messageTheme?.messageTextStyle?.color, + decoration: messageTheme?.messageTextStyle?.decoration, + decorationColor: messageTheme?.messageTextStyle?.decorationColor, + decorationStyle: messageTheme?.messageTextStyle?.decorationStyle, + fontFamily: messageTheme?.messageTextStyle?.fontFamily, + ), + ), + ) + .copyWith( + a: messageTheme?.messageLinksStyle, + p: messageTheme?.messageTextStyle, + ) + .merge(styleSheet), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index bbb0dc538..424e644c0 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -3,6 +3,9 @@ export 'package:photo_manager/photo_manager.dart' show ThumbnailSize, ThumbnailFormat; export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +export 'src/ai_assistant/ai_typing_indicator_view.dart'; +export 'src/ai_assistant/stream_typewriter_builder.dart'; +export 'src/ai_assistant/streaming_message_view.dart'; export 'src/attachment/attachment.dart'; export 'src/attachment/builder/attachment_widget_builder.dart'; export 'src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart'; @@ -69,6 +72,7 @@ export 'src/misc/back_button.dart'; export 'src/misc/connection_status_builder.dart'; export 'src/misc/date_divider.dart'; export 'src/misc/info_tile.dart'; +export 'src/misc/markdown_message.dart'; export 'src/misc/option_list_tile.dart'; export 'src/misc/reaction_icon.dart'; export 'src/misc/stream_neumorphic_button.dart'; diff --git a/packages/stream_chat_flutter/test/src/ai_assistant/ai_typing_indicator_view_test.dart b/packages/stream_chat_flutter/test/src/ai_assistant/ai_typing_indicator_view_test.dart new file mode 100644 index 000000000..9e14a993c --- /dev/null +++ b/packages/stream_chat_flutter/test/src/ai_assistant/ai_typing_indicator_view_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/ai_assistant/ai_typing_indicator_view.dart'; + +void main() { + group('AITypingIndicatorView', () { + testWidgets('displays the provided text', (WidgetTester tester) async { + const dotCount = 5; + const testText = 'AI is thinking'; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AITypingIndicatorView( + text: testText, + dotCount: dotCount, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 200 * dotCount)); + + expect(find.text(testText), findsOneWidget); + }); + + testWidgets('applies the provided textStyle', (WidgetTester tester) async { + const dotCount = 5; + const testText = 'AI is thinking'; + const textStyle = TextStyle(fontSize: 20, color: Colors.blue); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AITypingIndicatorView( + text: testText, + textStyle: textStyle, + dotCount: dotCount, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 200 * dotCount)); + + final textWidget = tester.widget(find.text(testText)); + expect(textWidget.style?.fontSize, equals(20)); + expect(textWidget.style?.color, equals(Colors.blue)); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/ai_assistant/streaming_message_view_test.dart b/packages/stream_chat_flutter/test/src/ai_assistant/streaming_message_view_test.dart new file mode 100644 index 000000000..1236a138a --- /dev/null +++ b/packages/stream_chat_flutter/test/src/ai_assistant/streaming_message_view_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/ai_assistant/streaming_message_view.dart'; + +void main() { + group('StreamingMessageView Tests', () { + testWidgets( + 'displays initial text', + (WidgetTester tester) async { + const testText = 'Hello, world!'; + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StreamingMessageView(text: testText), + ), + ), + ); + + expect(find.text(testText), findsOneWidget); + }, + ); + + testWidgets( + 'updates text progressively like a typewriter', + (WidgetTester tester) async { + const testText = 'Hello, world!'; + const typingSpeed = Duration(milliseconds: 20); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StreamingMessageView( + text: testText, + typingSpeed: typingSpeed, + ), + ), + ), + ); + + expect(find.text(testText), findsOneWidget); + + const updatedText = 'Hello, world! How are you?'; + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StreamingMessageView( + text: updatedText, + typingSpeed: typingSpeed, + ), + ), + ), + ); + + await tester.pump(typingSpeed * updatedText.length); + + expect(find.text(updatedText), findsOneWidget); + }, + ); + + testWidgets( + 'handles links correctly', + (WidgetTester tester) async { + const testText = '[Click me](https://example.com)'; + const typingSpeed = Duration(milliseconds: 20); + String? tappedLink; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamingMessageView( + text: testText, + typingSpeed: typingSpeed, + onTapLink: (String link, String? href, String title) { + tappedLink = href; + }, + ), + ), + ), + ); + + await tester.pump(typingSpeed * testText.length); + + final linkFinder = find.text('Click me'); + expect(linkFinder, findsOneWidget); + await tester.tap(linkFinder); + + expect(tappedLink, equals('https://example.com')); + }, + ); + }); +}