Skip to content

Commit 38d539a

Browse files
committed
fix: new loadingBuilder for stream message
1 parent c92d7e2 commit 38d539a

File tree

4 files changed

+93
-23
lines changed

4 files changed

+93
-23
lines changed

examples/flyer_chat/lib/gemini.dart

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ class Gemini extends StatefulWidget {
2929
}
3030

3131
class GeminiState extends State<Gemini> {
32+
// Set to `true` to show a "Thinking..." message immediately.
33+
// Set to `false` to wait for the first chunk before showing the message.
34+
final bool _isThinkingModel = true;
35+
3236
final _uuid = const Uuid();
3337
final _crossCache = CrossCache();
3438
final _scrollController = ScrollController();
@@ -285,7 +289,10 @@ class GeminiState extends State<Gemini> {
285289
final streamId = _uuid.v4();
286290
_currentStreamId = streamId;
287291
TextStreamMessage? streamMessage;
288-
var isFirstChunk = true;
292+
Timer? thinkingTimer;
293+
294+
// A flag to ensure the message is created only once.
295+
var messageInserted = false;
289296

290297
// Store scroll state per stream ID
291298
_reachedTargetScroll[streamId] = false;
@@ -295,6 +302,43 @@ class GeminiState extends State<Gemini> {
295302
_isStreaming = true;
296303
});
297304

305+
// Helper to create and insert the message, ensuring it only happens once.
306+
Future<void> createAndInsertMessage() async {
307+
if (messageInserted || !mounted) return;
308+
messageInserted = true;
309+
310+
// If the timer is still active, we beat it. Cancel it.
311+
thinkingTimer?.cancel();
312+
313+
streamMessage = TextStreamMessage(
314+
id: streamId,
315+
authorId: _agent.id,
316+
createdAt: DateTime.now().toUtc(),
317+
streamId: streamId,
318+
);
319+
await _chatController.insertMessage(streamMessage!);
320+
_streamManager.startStream(streamId, streamMessage!);
321+
}
322+
323+
if (_isThinkingModel) {
324+
// For thinking models, schedule the message insertion after a delay.
325+
thinkingTimer = Timer(const Duration(milliseconds: 300), () async {
326+
await createAndInsertMessage();
327+
// When timer fires, message is inserted, scroll to the bottom.
328+
// This is needed because we use shouldScrollToEndWhenAtBottom: false,
329+
// due to custom scroll logic below, so we must also scroll to the
330+
// thinking label manually.
331+
WidgetsBinding.instance.addPostFrameCallback((_) async {
332+
if (!_scrollController.hasClients || !mounted) return;
333+
await _scrollController.animateTo(
334+
_scrollController.position.maxScrollExtent,
335+
duration: const Duration(milliseconds: 250),
336+
curve: Curves.linearToEaseOut,
337+
);
338+
});
339+
});
340+
}
341+
298342
try {
299343
final response = _chatSession.sendMessageStream(content);
300344

@@ -305,18 +349,11 @@ class GeminiState extends State<Gemini> {
305349
final textChunk = chunk.text!;
306350
if (textChunk.isEmpty) return; // Skip empty chunks
307351

308-
if (isFirstChunk) {
309-
isFirstChunk = false;
310-
311-
// Create and insert the message ON the first chunk
312-
streamMessage = TextStreamMessage(
313-
id: streamId,
314-
authorId: _agent.id,
315-
createdAt: DateTime.now().toUtc(),
316-
streamId: streamId,
317-
);
318-
await _chatController.insertMessage(streamMessage!);
319-
_streamManager.startStream(streamId, streamMessage!);
352+
// On the first valid chunk, ensure the message is inserted.
353+
// This handles both non-thinking models and thinking models where
354+
// the response arrives before the timer.
355+
if (!messageInserted) {
356+
await createAndInsertMessage();
320357
}
321358

322359
// Ensure stream message exists before adding chunk
@@ -369,6 +406,7 @@ class GeminiState extends State<Gemini> {
369406
}
370407
},
371408
onDone: () async {
409+
thinkingTimer?.cancel();
372410
// Stream completed successfully (only if message was created)
373411
if (streamMessage != null) {
374412
await _streamManager.completeStream(streamId);
@@ -388,10 +426,12 @@ class GeminiState extends State<Gemini> {
388426
_reachedTargetScroll.remove(streamId);
389427
},
390428
onError: (error) async {
429+
thinkingTimer?.cancel();
391430
_handleStreamError(streamId, error, streamMessage);
392431
},
393432
);
394433
} catch (error) {
434+
thinkingTimer?.cancel();
395435
// Catch other potential errors during stream processing
396436
_handleStreamError(streamId, error, streamMessage);
397437
}

packages/flutter_chat_ui/lib/src/chat_animated_list/chat_animated_list.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,8 @@ class _ChatAnimatedListState extends State<ChatAnimatedList>
703703
},
704704
);
705705
} else {
706-
if (_scrollToBottomController.status == AnimationStatus.completed) {
706+
if (_scrollToBottomController.status == AnimationStatus.completed ||
707+
_scrollToBottomController.status == AnimationStatus.forward) {
707708
_scrollToBottomController.reverse();
708709
}
709710
}

packages/flyer_chat_text_stream_message/lib/src/flyer_chat_text_stream_message.dart

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
22
import 'package:flutter_chat_core/flutter_chat_core.dart';
33
import 'package:gpt_markdown/gpt_markdown.dart';
44
import 'package:provider/provider.dart';
5+
import 'package:shimmer/shimmer.dart';
56

67
import 'stream_state.dart';
78
import 'text_segment.dart';
@@ -91,6 +92,23 @@ class FlyerChatTextStreamMessage extends StatefulWidget {
9192
/// The callback function to handle link clicks.
9293
final void Function(String url, String title)? onLinkTap;
9394

95+
/// The text to display while in the loading state. Defaults to "Thinking".
96+
final String loadingText;
97+
98+
/// The base color for the shimmer loading animation.
99+
final Color? shimmerBaseColor;
100+
101+
/// The highlight color for the shimmer loading animation.
102+
final Color? shimmerHighlightColor;
103+
104+
/// The period of the shimmer loading animation.
105+
final Duration shimmerPeriod;
106+
107+
/// A builder to completely override the default loading widget.
108+
/// If provided, `loadingText`, `shimmerBaseColor`, and `shimmerHighlightColor` are ignored.
109+
final Widget Function(BuildContext context, TextStyle? paragraphStyle)?
110+
loadingBuilder;
111+
94112
/// Creates a widget to display a streaming text message.
95113
const FlyerChatTextStreamMessage({
96114
super.key,
@@ -110,6 +128,11 @@ class FlyerChatTextStreamMessage extends StatefulWidget {
110128
this.chunkAnimationDuration = const Duration(milliseconds: 350),
111129
this.mode = TextStreamMessageMode.animatedOpacity,
112130
this.onLinkTap,
131+
this.loadingText = 'Thinking',
132+
this.shimmerBaseColor,
133+
this.shimmerHighlightColor,
134+
this.shimmerPeriod = const Duration(milliseconds: 1000),
135+
this.loadingBuilder,
113136
});
114137

115138
@override
@@ -299,7 +322,7 @@ class _FlyerChatTextStreamMessageState extends State<FlyerChatTextStreamMessage>
299322
: null;
300323

301324
// Build text content based on segments
302-
final textContent = _buildTextContent(paragraphStyle);
325+
final textContent = _buildTextContent(paragraphStyle, theme);
303326

304327
// Build the message container and layout
305328
return Container(
@@ -317,15 +340,20 @@ class _FlyerChatTextStreamMessageState extends State<FlyerChatTextStreamMessage>
317340
);
318341
}
319342

320-
Widget _buildTextContent(TextStyle? paragraphStyle) {
343+
Widget _buildTextContent(TextStyle? paragraphStyle, _LocalTheme theme) {
321344
if (widget.streamState is StreamStateLoading) {
322-
return SizedBox(
323-
width: paragraphStyle?.lineHeight,
324-
height: paragraphStyle?.lineHeight,
325-
child: CircularProgressIndicator(
326-
strokeWidth: 2,
327-
color: paragraphStyle?.color,
328-
),
345+
if (widget.loadingBuilder != null) {
346+
return widget.loadingBuilder!(context, paragraphStyle);
347+
}
348+
349+
return Shimmer.fromColors(
350+
baseColor:
351+
widget.shimmerBaseColor ?? theme.onSurface.withValues(alpha: 0.3),
352+
highlightColor:
353+
widget.shimmerHighlightColor ??
354+
theme.onSurface.withValues(alpha: 0.8),
355+
period: widget.shimmerPeriod,
356+
child: Text(widget.loadingText, style: paragraphStyle),
329357
);
330358
}
331359

packages/flyer_chat_text_stream_message/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies:
1515
flutter_chat_core: ^2.6.2
1616
gpt_markdown: ^1.1.1
1717
provider: ^6.1.5
18+
shimmer: ^3.0.0
1819

1920
dev_dependencies:
2021
flutter_lints: ^6.0.0

0 commit comments

Comments
 (0)