fix: image attachment flashing and not persisting

This commit is contained in:
cogwheel0
2025-08-16 21:17:01 +05:30
parent 9be04ef2b9
commit d57ddf67c5
4 changed files with 310 additions and 338 deletions

View File

@@ -49,6 +49,7 @@ class _DocumentationMessageWidgetState
String _renderedContent = '';
Timer? _throttleTimer;
String? _pendingContent;
Widget? _cachedAvatar;
@override
void initState() {
@@ -67,6 +68,13 @@ class _DocumentationMessageWidgetState
_updateReasoningContent();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Build cached avatar when theme context is available
_buildCachedAvatar();
}
@override
void didUpdateWidget(DocumentationMessageWidget oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -77,6 +85,11 @@ class _DocumentationMessageWidgetState
_scheduleRenderUpdate(widget.message.content ?? '');
_updateReasoningContent();
}
// Rebuild cached avatar if model name changes
if (oldWidget.modelName != widget.modelName) {
_buildCachedAvatar();
}
}
void _updateReasoningContent() {
@@ -124,6 +137,41 @@ class _DocumentationMessageWidgetState
return content;
}
void _buildCachedAvatar() {
_cachedAvatar = Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary,
borderRadius: BorderRadius.circular(
AppBorderRadius.small,
),
),
child: Icon(
Icons.auto_awesome,
color: context.conduitTheme.buttonPrimaryText,
size: 12,
),
),
const SizedBox(width: Spacing.xs),
Text(
widget.modelName ?? 'Assistant',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
],
),
);
}
@override
void dispose() {
_fadeController.dispose();
@@ -158,29 +206,29 @@ class _DocumentationMessageWidgetState
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 16, left: 50, right: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: GestureDetector(
onLongPress: () => _toggleActions(),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: context.conduitTheme.chatBubbleUser,
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
border: Border.all(
color: context.conduitTheme.chatBubbleUserBorder,
width: BorderWidth.regular,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: GestureDetector(
onLongPress: () => _toggleActions(),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: context.conduitTheme.chatBubbleUser,
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
border: Border.all(
color: context.conduitTheme.chatBubbleUserBorder,
width: BorderWidth.regular,
),
),
child: Text(
widget.message.content,
style: TextStyle(
color: context.conduitTheme.chatBubbleUserText,
@@ -189,17 +237,17 @@ class _DocumentationMessageWidgetState
letterSpacing: 0.1,
),
),
// Action buttons for user messages
if (_showActions) ...[
const SizedBox(height: 12),
_buildUserActionButtons(),
],
],
),
),
),
),
],
),
// Action buttons below the message bubble
if (_showActions) ...[
const SizedBox(height: Spacing.sm),
_buildUserActionButtons(),
],
],
),
)
@@ -220,39 +268,8 @@ class _DocumentationMessageWidgetState
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Simplified AI Name and Avatar
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary,
borderRadius: BorderRadius.circular(
AppBorderRadius.small,
),
),
child: Icon(
Icons.auto_awesome,
color: context.conduitTheme.buttonPrimaryText,
size: 12,
),
),
const SizedBox(width: Spacing.xs),
Text(
widget.modelName ?? 'Assistant',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
],
),
),
// Cached AI Name and Avatar to prevent flashing
_cachedAvatar ?? const SizedBox.shrink(),
// Reasoning Section (if present)
if (_reasoningContent != null) ...[
@@ -364,16 +381,16 @@ class _DocumentationMessageWidgetState
_reasoningContent?.mainContent ??
widget.message.content,
),
// Action buttons - inline and minimal
if (_showActions) ...[
const SizedBox(height: Spacing.md),
_buildActionButtons(),
],
],
),
),
),
// Action buttons below the message content
if (_showActions) ...[
const SizedBox(height: Spacing.sm),
_buildActionButtons(),
],
],
),
)

View File

@@ -46,6 +46,10 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
// Cache for image base64 data to prevent repeated API calls
final Map<String, String?> _imageCache = {};
// Cache for rendered image widgets to prevent rebuilding during streaming
final Map<String, Widget> _imageWidgetCache = {};
String? _lastAttachmentIds;
@override
void initState() {
@@ -60,6 +64,207 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
);
}
Widget _buildAttachmentImages() {
if (widget.message.attachmentIds == null ||
widget.message.attachmentIds!.isEmpty) {
return const SizedBox.shrink();
}
final currentAttachmentIds = widget.message.attachmentIds!.join('_');
// Clear cache if attachment IDs changed
if (_lastAttachmentIds != currentAttachmentIds) {
_imageWidgetCache.clear();
_lastAttachmentIds = currentAttachmentIds;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.message.attachmentIds!.map<Widget>((attachmentId) {
// Return cached widget if available
if (_imageWidgetCache.containsKey(attachmentId)) {
return _imageWidgetCache[attachmentId]!;
}
// Build widget and cache it
final imageWidget = _buildSingleImageWidget(attachmentId);
_imageWidgetCache[attachmentId] = imageWidget;
return imageWidget;
}).toList(),
);
}
Widget _buildSingleImageWidget(String attachmentId) {
return Consumer(
builder: (context, ref, child) {
final api = ref.read(apiServiceProvider);
if (api == null) return const SizedBox.shrink();
return FutureBuilder<String?>(
key: ValueKey('img_$attachmentId'),
future: _getCachedImageBase64(api, attachmentId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
height: 150,
width: 200,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
),
child: Center(
child: CircularProgressIndicator(
color: context.conduitTheme.buttonPrimary,
strokeWidth: 2,
),
),
);
}
if (snapshot.hasError ||
!snapshot.hasData ||
snapshot.data == null) {
return Container(
height: 100,
width: 150,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
border: Border.all(
color: context.conduitTheme.textSecondary.withValues(
alpha: 0.3,
),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image_outlined,
color: context.conduitTheme.textSecondary,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Image unavailable',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
}
final base64Data = snapshot.data!;
try {
// Handle data URLs (data:image/...;base64,...)
String actualBase64;
if (base64Data.startsWith('data:')) {
// Extract base64 part from data URL
final commaIndex = base64Data.indexOf(',');
if (commaIndex != -1) {
actualBase64 = base64Data.substring(commaIndex + 1);
} else {
throw Exception('Invalid data URL format');
}
} else {
// Direct base64 string
actualBase64 = base64Data;
}
final imageBytes = base64.decode(actualBase64);
return Container(
margin: const EdgeInsets.only(bottom: Spacing.xs),
constraints: const BoxConstraints(
maxWidth: 300,
maxHeight: 300,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
child: Image.memory(
imageBytes,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 100,
width: 150,
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(
AppBorderRadius.sm,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Failed to load image',
style: TextStyle(
color: context.conduitTheme.error,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
},
),
),
);
} catch (e) {
return Container(
height: 100,
width: 150,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Invalid image format',
style: TextStyle(
color: context.conduitTheme.error,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
}
},
);
},
);
}
@override
void dispose() {
_fadeController.dispose();
@@ -279,183 +484,7 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
);
}
Widget _buildAttachmentImages() {
if (widget.message.attachmentIds == null ||
widget.message.attachmentIds!.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.message.attachmentIds!.map<Widget>((attachmentId) {
return Consumer(
builder: (context, ref, child) {
final api = ref.watch(apiServiceProvider);
if (api == null) return const SizedBox.shrink();
return FutureBuilder<String?>(
future: _getCachedImageBase64(api, attachmentId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
height: 150,
width: 200,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
),
child: Center(
child: CircularProgressIndicator(
color: context.conduitTheme.buttonPrimary,
strokeWidth: 2,
),
),
);
}
if (snapshot.hasError ||
!snapshot.hasData ||
snapshot.data == null) {
return Container(
height: 100,
width: 150,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
border: Border.all(
color: context.conduitTheme.textSecondary.withValues(
alpha: 0.3,
),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image_outlined,
color: context.conduitTheme.textSecondary,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Image unavailable',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
}
final base64Data = snapshot.data!;
try {
// Handle data URLs (data:image/...;base64,...)
String actualBase64;
if (base64Data.startsWith('data:')) {
// Extract base64 part from data URL
final commaIndex = base64Data.indexOf(',');
if (commaIndex != -1) {
actualBase64 = base64Data.substring(commaIndex + 1);
} else {
throw Exception('Invalid data URL format');
}
} else {
// Direct base64 string
actualBase64 = base64Data;
}
final imageBytes = base64.decode(actualBase64);
return Container(
margin: const EdgeInsets.only(bottom: Spacing.xs),
constraints: const BoxConstraints(
maxWidth: 300,
maxHeight: 300,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
child: Image.memory(
imageBytes,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 100,
width: 150,
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(
AppBorderRadius.sm,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Failed to load image',
style: TextStyle(
color: context.conduitTheme.error,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
},
),
),
);
} catch (e) {
return Container(
height: 100,
width: 150,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Invalid image format',
style: TextStyle(
color: context.conduitTheme.error,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
}
},
);
},
);
}).toList(),
);
}
Future<String?> _getCachedImageBase64(dynamic api, String fileId) async {
// Check cache first to prevent repeated API calls