feat: optimize chat animation (#7990)

* feat: optimize chat animation

* feat: optimize ai chat animation

* chore: move calculate min height logic to manager

* chore: remove unused code

* chore: adjust the animation on mobile
This commit is contained in:
Lucas 2025-05-27 20:06:07 +08:00 committed by GitHub
parent c78564ea79
commit ad86e1f55a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 518 additions and 147 deletions

View File

@ -124,12 +124,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
// Streaming completion
didFinishAnswerStream: () async => emit(
state.copyWith(promptResponseState: PromptResponseState.ready),
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
),
// Related questions
didReceiveRelatedQuestions: (questions) async =>
_handleRelatedQuestions(questions),
_handleRelatedQuestions(
questions,
emit,
),
// Message management
deleteMessage: (message) async => chatController.remove(message),
@ -277,7 +282,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
// Related questions handler
void _handleRelatedQuestions(List<String> questions) {
void _handleRelatedQuestions(
List<String> questions,
Emitter<ChatState> emit,
) {
if (questions.isEmpty) {
return;
}
@ -297,6 +305,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
chatController.insert(message);
emit(
state.copyWith(
promptResponseState: PromptResponseState.relatedQuestionsReady,
),
);
}
void _startListening() {

View File

@ -58,6 +58,9 @@ enum PromptResponseState {
ready,
sendingQuestion,
streamingAnswer,
relatedQuestionsReady;
bool get isReady => this == ready || this == relatedQuestionsReady;
}
class ChatFile extends Equatable {

View File

@ -0,0 +1,160 @@
import 'dart:math';
import 'package:universal_platform/universal_platform.dart';
class MessageHeightConstants {
static const String answerSuffix = '_ans';
static const String withoutMinHeightSuffix = '_without_min_height';
// This offset comes from the chat input box height + navigation bar height
// It's used to calculate the minimum height for answer messages
//
// navigation bar height + last user message height
// + last AI message height + chat input box height = screen height
static const double defaultDesktopScreenOffset = 220.0;
static const double defaultMobileScreenOffset = 304.0;
static const double relatedQuestionOffset = 72.0;
}
class ChatMessageHeightManager {
factory ChatMessageHeightManager() => _instance;
ChatMessageHeightManager._();
static final ChatMessageHeightManager _instance =
ChatMessageHeightManager._();
final Map<String, double> _heightCache = <String, double>{};
double get defaultScreenOffset {
if (UniversalPlatform.isMobile) {
return MessageHeightConstants.defaultMobileScreenOffset;
}
return MessageHeightConstants.defaultDesktopScreenOffset;
}
/// Cache a message height
void cacheHeight({
required String messageId,
required double height,
}) {
if (messageId.isEmpty || height <= 0) {
assert(false, 'messageId or height is invalid');
return;
}
_heightCache[messageId] = height;
}
void cacheWithoutMinHeight({
required String messageId,
required double height,
}) {
if (messageId.isEmpty || height <= 0) {
assert(false, 'messageId or height is invalid');
return;
}
_heightCache[messageId + MessageHeightConstants.withoutMinHeightSuffix] =
height;
}
double? getCachedHeight({
required String messageId,
}) {
if (messageId.isEmpty) return null;
final height = _heightCache[messageId];
return height;
}
double? getCachedWithoutMinHeight({
required String messageId,
}) {
if (messageId.isEmpty) return null;
final height =
_heightCache[messageId + MessageHeightConstants.withoutMinHeightSuffix];
return height;
}
/// Calculate minimum height for AI answer messages
///
/// For the user message, we don't need to calculate the minimum height
double calculateMinHeight({
required String messageId,
required double screenHeight,
}) {
if (!isAnswerMessage(messageId)) return 0.0;
final originalMessageId = getOriginalMessageId(
messageId: messageId,
);
final cachedHeight = getCachedHeight(
messageId: originalMessageId,
);
if (cachedHeight == null) {
return 0.0;
}
final calculatedHeight = screenHeight - cachedHeight - defaultScreenOffset;
return max(calculatedHeight, 0.0);
}
/// Calculate minimum height for related question messages
///
/// For the user message, we don't need to calculate the minimum height
double calculateRelatedQuestionMinHeight({
required String messageId,
}) {
final cacheHeight = getCachedHeight(
messageId: messageId,
);
final cacheHeightWithoutMinHeight = getCachedWithoutMinHeight(
messageId: messageId,
);
double minHeight = 0;
if (cacheHeight != null && cacheHeightWithoutMinHeight != null) {
minHeight = cacheHeight -
cacheHeightWithoutMinHeight -
MessageHeightConstants.relatedQuestionOffset;
}
minHeight = max(minHeight, 0);
return minHeight;
}
bool isAnswerMessage(String messageId) {
return messageId.endsWith(MessageHeightConstants.answerSuffix);
}
/// Get the original message ID from an answer message ID
///
/// Answer message ID is like: "message_id_ans"
/// Original message ID is like: "message_id"
String getOriginalMessageId({
required String messageId,
}) {
if (!isAnswerMessage(messageId)) {
return messageId;
}
return messageId.replaceAll(MessageHeightConstants.answerSuffix, '');
}
void removeFromCache({
required String messageId,
}) {
if (messageId.isEmpty) return;
_heightCache.remove(messageId);
final answerMessageId = messageId + MessageHeightConstants.answerSuffix;
_heightCache.remove(answerMessageId);
}
void clearCache() {
_heightCache.clear();
}
}

View File

@ -8,7 +8,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'application/chat_bloc.dart';
import 'application/chat_entity.dart';
import 'application/chat_member_bloc.dart';
class AIChatPage extends StatelessWidget {
@ -69,8 +68,7 @@ class AIChatPage extends StatelessWidget {
event.logicalKey == LogicalKeyboardKey.keyC &&
HardwareKeyboard.instance.isControlPressed) {
final chatBloc = context.read<ChatBloc>();
if (chatBloc.state.promptResponseState !=
PromptResponseState.ready) {
if (!chatBloc.state.promptResponseState.isReady) {
chatBloc.add(ChatEvent.stopStream());
return KeyEventResult.handled;
}

View File

@ -5,7 +5,6 @@ import 'dart:async';
import 'package:appflowy/util/debounce.dart';
import 'package:appflowy_backend/log.dart';
import 'package:diffutil_dart/diffutil.dart' as diffutil;
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
@ -13,6 +12,9 @@ import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
import 'package:provider/provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../application/chat_message_height_manager.dart';
import 'widgets/message_height_calculator.dart';
class ChatAnimatedList extends StatefulWidget {
const ChatAnimatedList({
super.key,
@ -38,21 +40,21 @@ class ChatAnimatedList extends StatefulWidget {
final double scrollBottomPadding;
@override
ChatAnimatedListState createState() => ChatAnimatedListState();
State<ChatAnimatedList> createState() => _ChatAnimatedListState();
}
class ChatAnimatedListState extends State<ChatAnimatedList>
class _ChatAnimatedListState extends State<ChatAnimatedList>
with SingleTickerProviderStateMixin {
late final ChatController _chatController = Provider.of<ChatController>(
late final ChatController chatController = Provider.of<ChatController>(
context,
listen: false,
);
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
late List<Message> oldList;
late StreamSubscription<ChatOperation> operationsSubscription;
late final AnimationController _scrollToBottomController;
late final Animation<double> _scrollToBottomAnimation;
Timer? _scrollToBottomShowTimer;
late final AnimationController scrollToBottomController;
late final Animation<double> scrollToBottomAnimation;
Timer? scrollToBottomShowTimer;
final ScrollOffsetController scrollOffsetController =
ScrollOffsetController();
@ -65,10 +67,10 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
int _lastUserMessageIndex = 0;
bool _isScrollingToBottom = false;
int lastUserMessageIndex = 0;
bool isScrollingToBottom = false;
final _loadPreviousMessagesDebounce = Debounce(
final loadPreviousMessagesDebounce = Debounce(
duration: const Duration(milliseconds: 200),
);
@ -76,15 +78,17 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
double initialAlignment = 1.0;
List<Message> messages = [];
final ChatMessageHeightManager heightManager = ChatMessageHeightManager();
@override
void initState() {
super.initState();
// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
oldList = List.from(chatController.messages);
operationsSubscription = chatController.operationsStream.listen((event) {
setState(() {
messages = _chatController.messages;
messages = chatController.messages;
});
switch (event.type) {
case ChatOperationType.insert:
@ -98,7 +102,7 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
oldList = List.from(chatController.messages);
break;
case ChatOperationType.remove:
assert(
@ -111,14 +115,14 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
);
_onRemoved(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
oldList = List.from(chatController.messages);
break;
case ChatOperationType.set:
final newList = _chatController.messages;
final newList = chatController.messages;
final updates = diffutil
.calculateDiff<Message>(
MessageListDiff(_oldList, newList),
MessageListDiff(oldList, newList),
)
.getUpdatesWithData();
@ -126,22 +130,22 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
_onDiffUpdate(updates.elementAt(i));
}
_oldList = List.from(newList);
oldList = List.from(newList);
break;
default:
break;
}
});
messages = _chatController.messages;
messages = chatController.messages;
_scrollToBottomController = AnimationController(
scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
scrollToBottomAnimation = CurvedAnimation(
parent: scrollToBottomController,
curve: Curves.easeInOut,
);
@ -152,15 +156,15 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
itemPositionsListener.itemPositions.addListener(() {
_handleLoadPreviousMessages();
});
// A trick to avoid the first message being scrolled to the top
}
@override
void dispose() {
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.dispose();
_operationsSubscription.cancel();
scrollToBottomShowTimer?.cancel();
scrollToBottomController.dispose();
operationsSubscription.cancel();
_clearMessageHeightCache();
super.dispose();
}
@ -168,9 +172,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
final height = MediaQuery.of(context).size.height;
// A trick to avoid the first message being scrolled to the top
// A trick to avoid the first message being scrolled to the top
initialScrollIndex = messages.length;
initialAlignment = 1.0;
if (messages.length <= 2) {
@ -198,29 +201,33 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
}
if (index == messages.length) {
return VSpace(height - 400);
return const SizedBox.shrink();
}
final message = messages[index];
return widget.itemBuilder(
context,
Tween<double>(begin: 1, end: 1).animate(
CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
return MessageHeightCalculator(
messageId: message.id,
onHeightMeasured: _cacheMessageHeight,
child: widget.itemBuilder(
context,
Tween<double>(begin: 1, end: 1).animate(
CurvedAnimation(
parent: scrollToBottomController,
curve: Curves.easeInOut,
),
),
message,
),
message,
);
},
),
builders.scrollToBottomBuilder?.call(
context,
_scrollToBottomAnimation,
scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: _scrollToBottomAnimation,
animation: scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
),
],
@ -235,11 +242,13 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
(message) => message.author.id == user.id,
);
if (lastUserMessageIndex == -1) {
// waiting for the ai answer message to be inserted
if (lastUserMessageIndex == -1 ||
lastUserMessageIndex + 1 >= messages.length) {
return;
}
if (_lastUserMessageIndex != lastUserMessageIndex) {
if (this.lastUserMessageIndex != lastUserMessageIndex) {
// scroll the current message to the top
await itemScrollController.scrollTo(
index: lastUserMessageIndex,
@ -248,15 +257,15 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
);
}
_lastUserMessageIndex = lastUserMessageIndex;
this.lastUserMessageIndex = lastUserMessageIndex;
}
Future<void> _handleScrollToBottom() async {
_isScrollingToBottom = true;
isScrollingToBottom = true;
_scrollToBottomShowTimer?.cancel();
scrollToBottomShowTimer?.cancel();
await _scrollToBottomController.reverse();
await scrollToBottomController.reverse();
await itemScrollController.scrollTo(
index: messages.length + 1,
@ -265,11 +274,11 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
curve: Curves.easeInOut,
);
_isScrollingToBottom = false;
isScrollingToBottom = false;
}
void _handleToggleScrollToBottom() {
if (_isScrollingToBottom) {
if (isScrollingToBottom) {
return;
}
@ -285,15 +294,15 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
if (maxItem.index > messages.length - 1 ||
(maxItem.index == messages.length - 1 &&
maxItem.itemTrailingEdge <= 1.01)) {
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.reverse();
scrollToBottomShowTimer?.cancel();
scrollToBottomController.reverse();
return;
}
_scrollToBottomShowTimer?.cancel();
_scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
scrollToBottomShowTimer?.cancel();
scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
scrollToBottomController.forward();
}
});
}
@ -307,21 +316,34 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
return;
}
_loadPreviousMessagesDebounce.call(
loadPreviousMessagesDebounce.call(
() {
widget.onLoadPreviousMessages?.call();
},
);
}
void _cacheMessageHeight(String messageId, double height) {
heightManager.cacheHeight(messageId: messageId, height: height);
}
void _clearMessageHeightCache() {
heightManager.clearCache();
}
Future<void> _onInserted(final int position, final Message data) async {
// scroll the last user message to the top if it's the last message
if (position == _oldList.length) {
if (position == oldList.length) {
await _scrollLastUserMessageToTop();
}
}
void _onRemoved(final int position, final Message data) {}
void _onRemoved(final int position, final Message data) {
// Clean up cached height for removed message
heightManager.removeFromCache(messageId: data.id);
}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
// do nothing
}
}

View File

@ -49,8 +49,7 @@ class _ChatFooterState extends State<ChatFooter> {
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return state.promptResponseState ==
PromptResponseState.ready;
return state.promptResponseState.isReady;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();

View File

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_height_manager.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_text_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/error_text_message.dart';
@ -45,20 +46,35 @@ class TextMessageWidget extends StatelessWidget {
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) {
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
final messages = context.read<ChatBloc>().chatController.messages;
final lastAIMessage = messages.lastWhere(
(e) =>
onetimeMessageTypeFromMeta(e.metadata) == null &&
(e.author.id == aiResponseUserId || e.author.id == systemUserId),
);
final minHeight =
ChatMessageHeightManager().calculateRelatedQuestionMinHeight(
messageId: lastAIMessage.id,
);
return Container(
constraints: BoxConstraints(
minHeight: minHeight,
),
child: RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) {
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
),
);
}
@ -87,6 +103,8 @@ class TextMessageWidget extends StatelessWidget {
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
final hasRelatedQuestions = state.promptResponseState ==
PromptResponseState.relatedQuestionsReady;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
@ -95,9 +113,9 @@ class TextMessageWidget extends StatelessWidget {
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming:
state.promptResponseState != PromptResponseState.ready,
isStreaming: !state.promptResponseState.isReady,
isLastMessage: isLastMessage,
hasRelatedQuestions: hasRelatedQuestions,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
onSelectedMetadata: (metadata) =>

View File

@ -187,9 +187,12 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown>
}
if (!_animations.containsKey(node.id)) {
final duration = UniversalPlatform.isMobile
? const Duration(milliseconds: 800)
: const Duration(milliseconds: 1600);
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
duration: duration,
);
final fade = Tween<double>(
begin: 0,

View File

@ -3,7 +3,9 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_height_manager.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy/plugins/ai_chat/presentation/widgets/message_height_calculator.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
@ -42,6 +44,7 @@ class ChatAIMessageWidget extends StatelessWidget {
this.isStreaming = false,
this.isSelectingMessages = false,
this.enableAnimation = true,
this.hasRelatedQuestions = false,
});
final User user;
@ -61,6 +64,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final bool isLastMessage;
final bool isSelectingMessages;
final bool enableAnimation;
final bool hasRelatedQuestions;
@override
Widget build(BuildContext context) {
@ -79,74 +83,98 @@ class ChatAIMessageWidget extends StatelessWidget {
final loadingText = blocState.progress?.step ??
LocaleKeys.chat_generatingResponse.tr();
return Padding(
padding: AIChatUILayout.messageMargin,
child: blocState.messageState.when(
loading: () => ChatAIMessageBubble(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
),
ready: () {
return blocState.text.isEmpty
? _LoadingMessage(
message: message,
loadingText: loadingText,
)
: _NonEmptyMessage(
user: user,
messageUserId: messageUserId,
message: message,
stream: stream,
questionId: questionId,
chatId: chatId,
refSourceJsonString: refSourceJsonString,
onStopStream: onStopStream,
onSelectedMetadata: onSelectedMetadata,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
isLastMessage: isLastMessage,
isStreaming: isStreaming,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
);
},
onError: (error) {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
);
},
onAIResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
);
},
onAIImageResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
);
},
onAIMaxRequired: (message) {
return ChatErrorMessageWidget(
errorMessage: message,
);
},
onInitializingLocalAI: () {
onStopStream();
// Calculate minimum height only for the last AI answer message
double minHeight = 0;
if (isLastMessage && !hasRelatedQuestions) {
final screenHeight = MediaQuery.of(context).size.height;
minHeight = ChatMessageHeightManager().calculateMinHeight(
messageId: message.id,
screenHeight: screenHeight,
);
}
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
return Container(
alignment: Alignment.topLeft,
constraints: BoxConstraints(
minHeight: minHeight,
),
padding: AIChatUILayout.messageMargin,
child: MessageHeightCalculator(
messageId: message.id,
onHeightMeasured: (messageId, height) {
ChatMessageHeightManager().cacheWithoutMinHeight(
messageId: messageId,
height: height,
);
},
aiFollowUp: (followUpData) {
return const SizedBox.shrink();
},
child: blocState.messageState.when(
loading: () => ChatAIMessageBubble(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
),
ready: () {
return blocState.text.isEmpty
? _LoadingMessage(
message: message,
loadingText: loadingText,
)
: _NonEmptyMessage(
user: user,
messageUserId: messageUserId,
message: message,
stream: stream,
questionId: questionId,
chatId: chatId,
refSourceJsonString: refSourceJsonString,
onStopStream: onStopStream,
onSelectedMetadata: onSelectedMetadata,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
isLastMessage: isLastMessage,
isStreaming: isStreaming,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
);
},
onError: (error) {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
);
},
onAIResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
);
},
onAIImageResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
);
},
onAIMaxRequired: (message) {
return ChatErrorMessageWidget(
errorMessage: message,
);
},
onInitializingLocalAI: () {
onStopStream();
return ChatErrorMessageWidget(
errorMessage: LocaleKeys
.settings_aiPage_keys_localAIInitializing
.tr(),
);
},
aiFollowUp: (followUpData) {
return const SizedBox.shrink();
},
),
),
);
},

View File

@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// Callback type for height measurement
typedef HeightMeasuredCallback = void Function(String messageId, double height);
/// Widget that measures and caches message heights with proper lifecycle management
class MessageHeightCalculator extends StatefulWidget {
const MessageHeightCalculator({
super.key,
required this.messageId,
required this.child,
this.onHeightMeasured,
});
final String messageId;
final Widget child;
final HeightMeasuredCallback? onHeightMeasured;
@override
State<MessageHeightCalculator> createState() =>
_MessageHeightCalculatorState();
}
class _MessageHeightCalculatorState extends State<MessageHeightCalculator>
with WidgetsBindingObserver {
final GlobalKey measureKey = GlobalKey();
double? lastMeasuredHeight;
bool isMeasuring = false;
int measurementAttempts = 0;
static const int maxMeasurementAttempts = 3;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scheduleMeasurement();
}
@override
void didUpdateWidget(MessageHeightCalculator oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.messageId != widget.messageId) {
_resetMeasurement();
_scheduleMeasurement();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeMetrics() {
if (mounted) {
_scheduleMeasurement();
}
}
@override
Widget build(BuildContext context) {
return KeyedSubtree(
key: measureKey,
child: widget.child,
);
}
void _resetMeasurement() {
lastMeasuredHeight = null;
isMeasuring = false;
measurementAttempts = 0;
}
void _scheduleMeasurement() {
if (isMeasuring || !mounted) return;
isMeasuring = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_measureHeight();
});
}
void _measureHeight() {
if (!mounted || measurementAttempts >= maxMeasurementAttempts) {
isMeasuring = false;
return;
}
measurementAttempts++;
try {
final renderBox =
measureKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null || !renderBox.hasSize) {
// Retry measurement in next frame if render box is not ready
if (measurementAttempts < maxMeasurementAttempts) {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) _measureHeight();
});
} else {
isMeasuring = false;
}
return;
}
final height = renderBox.size.height;
if (lastMeasuredHeight == null ||
(height - (lastMeasuredHeight ?? 0)).abs() > 1.0) {
lastMeasuredHeight = height;
widget.onHeightMeasured?.call(widget.messageId, height);
}
isMeasuring = false;
} catch (e) {
isMeasuring = false;
}
}
}