build method
Describes the part of the user interface represented by this widget.
The framework calls this method when this widget is inserted into the tree in a given BuildContext and when the dependencies of this widget change (e.g., an InheritedWidget referenced by this widget changes). This method can potentially be called in every frame and should not have any side effects beyond building a widget.
The framework replaces the subtree below this widget with the widget returned by this method, either by updating the existing subtree or by removing the subtree and inflating a new subtree, depending on whether the widget returned by this method can update the root of the existing subtree, as determined by calling Widget.canUpdate.
Typically implementations return a newly created constellation of widgets that are configured with information from this widget's constructor and from the given BuildContext.
The given BuildContext contains information about the location in the tree at which this widget is being built. For example, the context provides the set of inherited widgets for this location in the tree. A given widget might be built with multiple different BuildContext arguments over time if the widget is moved around the tree or if the widget is inserted into the tree in multiple places at once.
The implementation of this method must only depend on:
- the fields of the widget, which themselves must not change over time, and
- any ambient state obtained from the
context
using BuildContext.dependOnInheritedWidgetOfExactType.
If a widget's build method is to depend on anything else, use a StatefulWidget instead.
See also:
- StatelessWidget, which contains the discussion on performance considerations.
Implementation
@override
Widget build(BuildContext context) {
final appTheme = Theme.of(context);
final isUser = message.isUser;
// Determine styles based on user/bot and error state
final decoration = isError
? _getErrorBubbleDecoration(context, appTheme)
: (isUser
? theme.getUserBubbleDecoration(appTheme)
: theme.getBotBubbleDecoration(appTheme));
final textStyle = isError
? _getErrorTextStyle(context, appTheme)
: (isUser
? theme.getUserTextStyle(appTheme)
: theme.getBotTextStyle(appTheme));
// Default margins for the ALIGNED bubble (when no avatar is shown)
final EdgeInsetsGeometry effectiveMargin = margin ??
EdgeInsets.only(
left: isUser ? 40 : 8, // Standard margin when no avatar
right: isUser ? 8 : 40, // Standard margin when no avatar
top: 4,
bottom: 4,
);
// Default padding inside the bubble
final effectivePadding =
padding ?? const EdgeInsets.symmetric(vertical: 10, horizontal: 14);
// --- Build citations widget ---
Widget citationsWidget = const SizedBox.shrink(); // Empty by default
final citations = message.citations;
if (showCitations && citations != null && citations.isNotEmpty) {
citationsWidget = Padding(
padding: const EdgeInsets.only(top: 8.0), // Space above citations
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Optional "Sources:" title
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(
"Sources:",
style: textStyle.copyWith(
fontSize: 11,
fontWeight: FontWeight.bold,
color: textStyle.color?.withAlpha(
(255 * 0.8).round()) // Slightly dimmer title
),
),
),
// List citations
...citations.map((citation) {
final title = citation['title'] as String? ??
citation['url'] as String? ??
'Source'; // Use URL as title if title missing
final url = citation['url'] as String?;
bool isLink = url != null && url.isNotEmpty;
return InkWell(
onTap: isLink ? () => _launchUrl(url, context) : null,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
// Use row for icon + text
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icon indicating it's a link or just a source
Icon(
isLink ? Icons.link : Icons.info_outline,
size: 12,
color: textStyle.color?.withAlpha((255 * 0.8).round()),
),
const SizedBox(width: 4),
// Citation text (title)
Expanded(
child: Text(
title,
style: textStyle.copyWith(
fontSize: 11,
// Style link differently
color: isLink
? (theme.sendButtonColor ??
appTheme.colorScheme
.primary) // Use theme accent or primary
: textStyle.color?.withAlpha(
(255 * 0.7).round()), // Dim non-links
decoration: isLink
? TextDecoration.underline
: TextDecoration.none,
decorationColor: isLink
? (theme.sendButtonColor ??
appTheme.colorScheme.primary)
: null,
),
maxLines:
1, // Prevent long titles from wrapping excessively
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}),
],
),
);
}
// --- End citations widget ---
// --- Build main bubble content ---
Widget bubbleContentContainer = Container(
// Renamed for clarity before InkWell wrap
padding: effectivePadding,
decoration: decoration,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, // Align content left
mainAxisSize: MainAxisSize.min, // Fit content height
children: [
// Sender name if provided and not user
if (senderName != null && senderName!.isNotEmpty && !isUser)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
senderName!,
style: textStyle.copyWith(
fontWeight: FontWeight.bold,
fontSize: 12,
color: textStyle.color
?.withAlpha((255 * 0.9).round()), // Slightly dimmer name
),
),
),
// *** RENDER MARKDOWN FOR BOT, PLAIN TEXT FOR USER/ERROR ***
if (message.message.isNotEmpty ||
isError) // Show even if empty if it's an error message
if (!isUser &&
!isError) // Render Markdown only for non-user, non-error messages
MarkdownBody(
data: message.message, // Pass the markdown string here
selectable: true, // Allow text selection
styleSheet: MarkdownStyleSheet.fromTheme(appTheme).copyWith(
// Base style on app theme
p: textStyle, // Apply the bubble's base text style to paragraphs
// You can customize other elements like headings, links, code blocks etc.
// h1: textStyle.copyWith(fontSize: 20, fontWeight: FontWeight.bold),
// a: TextStyle(color: theme.sendButtonColor ?? appTheme.colorScheme.primary, decoration: TextDecoration.underline),
// code: TextStyle(backgroundColor: appTheme.colorScheme.surfaceVariant, fontFamily: 'monospace'),
),
onTapLink: (text, href, title) {
// Handle link taps
if (href != null) {
_launchUrl(href, context);
}
},
// Add other configurations like image builders if needed
// imageBuilder: (uri, title, alt) => Image.network(uri.toString()),
)
else // For user messages or errors, use SelectableText
SelectableText(message.message, style: textStyle),
// *** END MARKDOWN RENDERING ***
// Typing indicator OR Citations/Timestamp
if (message.isWaiting)
Padding(
padding: const EdgeInsets.only(top: 8),
// Use the imported BubbleTypingIndicator
child: BubbleTypingIndicator(
color: theme.loadingIndicatorColor ??
(isUser
? appTheme.colorScheme.onPrimary
: appTheme.colorScheme.primary)
.withAlpha((255 * 0.7).round()),
bubbleSize: 8,
spacing: 4,
),
)
else ...[
// Show these only if not waiting
// Citations widget (built above)
citationsWidget,
// Timestamp if enabled and available
if (showTimestamp && message.timestamp != null)
Align(
alignment: Alignment.bottomRight,
child: Padding(
// Adjust top padding based on whether citations are shown
padding: EdgeInsets.only(
top: (citationsWidget is SizedBox &&
citationsWidget.height == 0)
? 4
: 8),
child: Text(
_formatTimestamp(message.timestamp!),
style: textStyle.copyWith(
fontSize: 10,
fontWeight:
FontWeight.w300, // Lighter weight for timestamp
color: textStyle.color
?.withAlpha((255 * 0.6).round()), // Dimmer timestamp
),
),
),
),
]
],
),
);
// --- End main bubble content container ---
// --- Add InkWell for tap/long-press AFTER content is built ---
// This makes the whole bubble area tappable, including padding
Widget bubbleInteractive = Material(
// Material needed for InkWell splash and borderRadius clipping
color: Colors.transparent, // Make material transparent
// ignore: unnecessary_type_check
borderRadius: (decoration is BoxDecoration)
? (decoration.borderRadius
as BorderRadius?) // Cast BorderRadiusGeometry? to BorderRadius?
: BorderRadius.circular(16), // Fallback if needed
clipBehavior: Clip.antiAlias, // Ensure InkWell splash stays within bounds
child: InkWell(
onTap: onTap,
// Add default long press for copying if no custom one is provided
onLongPress: onLongPress ??
(isError || message.message.isEmpty
? null
: () => _copyToClipboard(context)),
// borderRadius applied to Material above
child: bubbleContentContainer, // Wrap the content container
),
);
// --- End InkWell ---
// --- Row for Avatar + Bubble ---
if (showAvatar) {
final effectiveAvatar = avatar ?? _defaultAvatar(isUser, appTheme);
// Define specific padding for the Row when avatar is shown
final double verticalPadding = (effectiveMargin is EdgeInsets)
? effectiveMargin.top // Use top/bottom from original margin
: 4.0; // Default vertical padding
final EdgeInsets avatarRowPadding = EdgeInsets.symmetric(
horizontal: 8.0, // Standard horizontal padding when avatar is shown
vertical: verticalPadding,
);
return Padding(
padding: avatarRowPadding, // Use the calculated EdgeInsets
child: Row(
mainAxisAlignment:
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment:
CrossAxisAlignment.end, // Align avatar bottom with bubble bottom
children: isUser
? [
Flexible(
child: bubbleInteractive), // Bubble takes available space
const SizedBox(width: 8),
effectiveAvatar, // Avatar on the right for user
]
: [
effectiveAvatar, // Avatar on the left for bot
const SizedBox(width: 8),
Flexible(child: bubbleInteractive),
],
),
);
}
// --- End Avatar Row ---
// --- Bubble only (no avatar) ---
// Align the bubble itself left or right
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
// Constrain max width to prevent bubble taking full screen width
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
margin:
effectiveMargin, // Use the original margin (passed in or default)
child: bubbleInteractive, // The InkWell-wrapped content
),
);
// --- End Bubble only ---
}