From 9a5c5a573f02cd0c5c89a0de4543c124d472cc4b Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:39:30 +0530 Subject: [PATCH] fix: fixed more widgets --- .gitignore | 1 - .../widgets/documentation_message_widget.dart | 485 +++++++---- .../widgets/enhanced_image_attachment.dart | 401 +++++++++ .../chat/widgets/modern_message_bubble.dart | 815 ++++++------------ .../widgets/markdown/markdown_config.dart | 150 +++- .../markdown/streaming_markdown_widget.dart | 13 +- 6 files changed, 1132 insertions(+), 733 deletions(-) create mode 100644 lib/features/chat/widgets/enhanced_image_attachment.dart diff --git a/.gitignore b/.gitignore index b939929..f976826 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ .swiftpm/ migrate_working_dir/ AGENTS.md -flutter_*.png # IntelliJ related *.iml diff --git a/lib/features/chat/widgets/documentation_message_widget.dart b/lib/features/chat/widgets/documentation_message_widget.dart index 724a15a..516d4c6 100644 --- a/lib/features/chat/widgets/documentation_message_widget.dart +++ b/lib/features/chat/widgets/documentation_message_widget.dart @@ -7,6 +7,7 @@ import 'dart:io' show Platform; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; import '../../../core/utils/reasoning_parser.dart'; +import 'enhanced_image_attachment.dart'; class DocumentationMessageWidget extends ConsumerStatefulWidget { final dynamic message; @@ -138,7 +139,7 @@ class _DocumentationMessageWidgetState void _buildCachedAvatar() { _cachedAvatar = Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ Container( @@ -175,6 +176,7 @@ class _DocumentationMessageWidgetState void dispose() { _fadeController.dispose(); _slideController.dispose(); + _throttleTimer?.cancel(); super.dispose(); } @@ -202,54 +204,74 @@ class _DocumentationMessageWidgetState } Widget _buildUserMessage() { - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 16, left: 50, right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ + final hasImages = widget.message.attachmentIds != null && + widget.message.attachmentIds!.isNotEmpty; + final hasText = widget.message.content.isNotEmpty; + + return GestureDetector( + onLongPress: () => _toggleActions(), + behavior: HitTestBehavior.translucent, + child: Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12, left: 50, right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Display images outside and above the text bubble + if (hasImages) ...[ Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Flexible( - child: GestureDetector( - onLongPress: () => _toggleActions(), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + child: _buildUserAttachmentImages(), + ), + ], + ), + if (hasText) const SizedBox(height: Spacing.xs), + ], + + // Display text bubble if there's text content + if (hasText) + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: context.conduitTheme.chatBubbleUser, + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + border: Border.all( + color: context.conduitTheme.chatBubbleUserBorder, + width: BorderWidth.regular, ), - 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, - fontSize: AppTypography.bodyMedium, - height: 1.5, - letterSpacing: 0.1, - ), + ), + child: Text( + widget.message.content, + style: TextStyle( + color: context.conduitTheme.chatBubbleUserText, + fontSize: AppTypography.bodyMedium, + height: 1.3, + letterSpacing: 0.1, ), ), ), ), ], ), - - // Action buttons below the message bubble - if (_showActions) ...[ - const SizedBox(height: Spacing.sm), - _buildUserActionButtons(), - ], + + // Action buttons below the message bubble + if (_showActions) ...[ + const SizedBox(height: Spacing.sm), + _buildUserActionButtons(), ], - ), - ) + ], + ), + ), + ) .animate() .fadeIn(duration: const Duration(milliseconds: 400)) .slideX( @@ -261,138 +283,146 @@ class _DocumentationMessageWidgetState } Widget _buildDocumentationMessage() { - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 24, left: 12, right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Cached AI Name and Avatar to prevent flashing - _cachedAvatar ?? const SizedBox.shrink(), + return GestureDetector( + onLongPress: () => _toggleActions(), + behavior: HitTestBehavior.translucent, + child: Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 16, left: 12, right: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Cached AI Name and Avatar to prevent flashing + _cachedAvatar ?? const SizedBox.shrink(), - // Reasoning Section (if present) - if (_reasoningContent != null) ...[ - InkWell( - onTap: () => setState(() => _showReasoning = !_showReasoning), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, + // Reasoning Section (if present) + if (_reasoningContent != null) ...[ + InkWell( + onTap: () => setState(() => _showReasoning = !_showReasoning), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues( + alpha: 0.5, ), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceContainer.withValues( - alpha: 0.5, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: context.conduitTheme.dividerColor, - width: BorderWidth.thin, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _showReasoning - ? Icons.expand_less_rounded - : Icons.expand_more_rounded, - size: 16, - color: context.conduitTheme.textSecondary, - ), - const SizedBox(width: Spacing.xs), - Icon( - Icons.psychology_outlined, - size: 14, - color: context.conduitTheme.buttonPrimary, - ), - const SizedBox(width: Spacing.xs), - Text( - _reasoningContent!.summary.isNotEmpty - ? _reasoningContent!.summary - : 'Thought for ${_reasoningContent!.formattedDuration}', - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: context.conduitTheme.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ], + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, ), ), - ), - - // Expandable reasoning content - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: Container( - margin: const EdgeInsets.only(top: Spacing.sm), - padding: const EdgeInsets.all(Spacing.sm), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceContainer.withValues( - alpha: 0.3, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: context.conduitTheme.dividerColor, - width: BorderWidth.thin, - ), - ), - child: SelectableText( - _reasoningContent!.cleanedReasoning, - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: context.conduitTheme.textSecondary, - fontFamily: 'monospace', - height: 1.4, - ), - ), - ), - crossFadeState: _showReasoning - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), - ), - - const SizedBox(height: Spacing.md), - ], - - // Documentation-style content without heavy bubble; premium markdown - GestureDetector( - onLongPress: () => _toggleActions(), - child: SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - if (widget.isStreaming && - (widget.message.content.trim().isEmpty || - widget.message.content == '[TYPING_INDICATOR]')) - _buildTypingIndicator() - else if (widget.isStreaming && - widget.message.content.isNotEmpty && - widget.message.content != '[TYPING_INDICATOR]') - // While streaming, render markdown with throttling and safety fixes - _buildEnhancedMarkdownContent(_renderedContent) - else - // After streaming finishes (or static content), render full markdown - _buildEnhancedMarkdownContent( - _reasoningContent?.mainContent ?? - widget.message.content, + Icon( + _showReasoning + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 16, + color: context.conduitTheme.textSecondary, + ), + const SizedBox(width: Spacing.xs), + Icon( + Icons.psychology_outlined, + size: 14, + color: context.conduitTheme.buttonPrimary, + ), + const SizedBox(width: Spacing.xs), + Text( + _reasoningContent!.summary.isNotEmpty + ? _reasoningContent!.summary + : 'Thought for ${_reasoningContent!.formattedDuration}', + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: context.conduitTheme.textSecondary, + fontWeight: FontWeight.w500, ), + ), ], ), ), ), - // Action buttons below the message content - if (_showActions) ...[ - const SizedBox(height: Spacing.sm), - _buildActionButtons(), - ], + // Expandable reasoning content + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Container( + margin: const EdgeInsets.only(top: Spacing.sm), + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.thin, + ), + ), + child: SelectableText( + _reasoningContent!.cleanedReasoning, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: context.conduitTheme.textSecondary, + fontFamily: 'monospace', + height: 1.4, + ), + ), + ), + crossFadeState: _showReasoning + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + + const SizedBox(height: Spacing.md), ], - ), - ) + + // Documentation-style content without heavy bubble; premium markdown + SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Display attachment images if any (for user uploaded images) + if (widget.message.attachmentIds != null && + widget.message.attachmentIds!.isNotEmpty) ...[ + _buildAttachmentImages(), + const SizedBox(height: Spacing.md), + ], + + if (widget.isStreaming && + (widget.message.content.trim().isEmpty || + widget.message.content == '[TYPING_INDICATOR]')) + _buildTypingIndicator() + else if (widget.isStreaming && + widget.message.content.isNotEmpty && + widget.message.content != '[TYPING_INDICATOR]') + // While streaming, render markdown with throttling and safety fixes + _buildEnhancedMarkdownContent(_renderedContent) + else + // After streaming finishes (or static content), render full markdown + _buildEnhancedMarkdownContent( + _reasoningContent?.mainContent ?? + widget.message.content, + ), + ], + ), + ), + + // Action buttons below the message content + if (_showActions) ...[ + const SizedBox(height: Spacing.sm), + _buildActionButtons(), + ], + ], + ), + ), + ) .animate() .fadeIn(duration: const Duration(milliseconds: 300)) .slideY( @@ -408,15 +438,150 @@ class _DocumentationMessageWidgetState return const SizedBox.shrink(); } + // Process content to ensure proper image rendering + final processedContent = _processContentForImages(content); + return StreamingMarkdownWidget( - staticContent: content, + staticContent: processedContent, isStreaming: widget.isStreaming, ); } + String _processContentForImages(String content) { + // Check if content contains image markdown or base64 data URLs + // This ensures images generated by AI are properly formatted + + // Pattern to detect base64 images that might not be in markdown format + final base64Pattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*'); + + // If we find base64 images not wrapped in markdown, wrap them + if (base64Pattern.hasMatch(content) && !content.contains('![')) { + content = content.replaceAllMapped(base64Pattern, (match) { + final imageData = match.group(0)!; + // Check if this image is already in markdown format + final markdownCheck = RegExp(r'!\[.*?\]\(' + RegExp.escape(imageData) + r'\)'); + if (!markdownCheck.hasMatch(content)) { + return '\n![Generated Image]($imageData)\n'; + } + return imageData; + }); + } + + return content; + } + Widget _buildUserAttachmentImages() { + if (widget.message.attachmentIds == null || + widget.message.attachmentIds!.isEmpty) { + return const SizedBox.shrink(); + } - // Removed lightweight streaming text; we now stream markdown with throttling + final imageCount = widget.message.attachmentIds!.length; + + // Similar to iMessage style but adapted for documentation widget + if (imageCount == 1) { + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + child: EnhancedImageAttachment( + attachmentId: widget.message.attachmentIds![0], + isUserMessage: true, + constraints: const BoxConstraints( + maxWidth: 280, + maxHeight: 350, + ), + ), + ); + } else if (imageCount == 2) { + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: widget.message.attachmentIds!.map((attachmentId) { + return Padding( + padding: EdgeInsets.only( + left: attachmentId == widget.message.attachmentIds!.first + ? 0 + : Spacing.xs, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + child: EnhancedImageAttachment( + attachmentId: attachmentId, + isUserMessage: true, + constraints: const BoxConstraints( + maxWidth: 135, + maxHeight: 180, + ), + ), + ), + ); + }).toList(), + ); + } else { + return Container( + constraints: const BoxConstraints(maxWidth: 280), + child: Wrap( + alignment: WrapAlignment.end, + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: widget.message.attachmentIds!.map((attachmentId) { + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: EnhancedImageAttachment( + attachmentId: attachmentId, + isUserMessage: true, + constraints: BoxConstraints( + maxWidth: imageCount == 3 ? 135 : 90, + maxHeight: imageCount == 3 ? 135 : 90, + ), + ), + ); + }).toList(), + ), + ); + } + } + + Widget _buildAttachmentImages() { + if (widget.message.attachmentIds == null || + widget.message.attachmentIds!.isEmpty) { + return const SizedBox.shrink(); + } + + final imageCount = widget.message.attachmentIds!.length; + + // Display images in a clean, modern layout for assistant messages + if (imageCount == 1) { + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: EnhancedImageAttachment( + attachmentId: widget.message.attachmentIds![0], + isMarkdownFormat: true, + constraints: const BoxConstraints( + maxWidth: 500, + maxHeight: 400, + ), + ), + ); + } else { + return Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: widget.message.attachmentIds!.map((attachmentId) { + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: EnhancedImageAttachment( + attachmentId: attachmentId, + isMarkdownFormat: true, + constraints: BoxConstraints( + maxWidth: imageCount == 2 ? 245 : 160, + maxHeight: imageCount == 2 ? 245 : 160, + ), + ), + ); + }).toList(), + ); + } + } Widget _buildTypingIndicator() { return Consumer( @@ -584,4 +749,4 @@ class _DocumentationMessageWidgetState ], ); } -} +} \ No newline at end of file diff --git a/lib/features/chat/widgets/enhanced_image_attachment.dart b/lib/features/chat/widgets/enhanced_image_attachment.dart new file mode 100644 index 0000000..c7ad857 --- /dev/null +++ b/lib/features/chat/widgets/enhanced_image_attachment.dart @@ -0,0 +1,401 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../core/providers/app_providers.dart'; + +// Global cache for image data to prevent reloading +final _globalImageCache = {}; + +class EnhancedImageAttachment extends ConsumerStatefulWidget { + final String attachmentId; + final bool isMarkdownFormat; + final VoidCallback? onTap; + final BoxConstraints? constraints; + final bool isUserMessage; + + const EnhancedImageAttachment({ + super.key, + required this.attachmentId, + this.isMarkdownFormat = false, + this.onTap, + this.constraints, + this.isUserMessage = false, + }); + + @override + ConsumerState createState() => + _EnhancedImageAttachmentState(); +} + +class _EnhancedImageAttachmentState + extends ConsumerState + with AutomaticKeepAliveClientMixin { + String? _cachedImageData; + bool _isLoading = true; + String? _errorMessage; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _loadImage(); + } + + Future _loadImage() async { + // Check global cache first + if (_globalImageCache.containsKey(widget.attachmentId)) { + if (mounted) { + setState(() { + _cachedImageData = _globalImageCache[widget.attachmentId]; + _isLoading = false; + }); + } + return; + } + + // Check if this is already a data URL or base64 image + if (widget.attachmentId.startsWith('data:') || + widget.attachmentId.startsWith('http')) { + _globalImageCache[widget.attachmentId] = widget.attachmentId; + if (mounted) { + setState(() { + _cachedImageData = widget.attachmentId; + _isLoading = false; + }); + } + return; + } + + final api = ref.read(apiServiceProvider); + if (api == null) { + if (mounted) { + setState(() { + _errorMessage = 'API service not available'; + _isLoading = false; + }); + } + return; + } + + try { + // Get file info to check if it's an image + final fileInfo = await api.getFileInfo(widget.attachmentId); + final fileName = _extractFileName(fileInfo); + final ext = fileName.toLowerCase().split('.').last; + + if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].contains(ext)) { + if (mounted) { + setState(() { + _errorMessage = 'Not an image file: $fileName'; + _isLoading = false; + }); + } + return; + } + + // Get the image content + final fileContent = await api.getFileContent(widget.attachmentId); + + // Cache globally + _globalImageCache[widget.attachmentId] = fileContent; + + // Limit cache size + if (_globalImageCache.length > 50) { + _globalImageCache.remove(_globalImageCache.keys.first); + } + + if (mounted) { + setState(() { + _cachedImageData = fileContent; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Failed to load image: ${e.toString()}'; + _isLoading = false; + }); + } + } + } + + String _extractFileName(Map fileInfo) { + return fileInfo['filename'] ?? + fileInfo['meta']?['name'] ?? + fileInfo['name'] ?? + fileInfo['file_name'] ?? + fileInfo['original_name'] ?? + fileInfo['original_filename'] ?? + 'unknown'; + } + + @override + Widget build(BuildContext context) { + super.build(context); // Required for AutomaticKeepAliveClientMixin + + if (_isLoading) { + return _buildLoadingState(); + } + + if (_errorMessage != null) { + return _buildErrorState(); + } + + if (_cachedImageData == null) { + return const SizedBox.shrink(); + } + + // Handle different image data formats + if (_cachedImageData!.startsWith('http')) { + return _buildNetworkImage(); + } else { + return _buildBase64Image(); + } + } + + Widget _buildLoadingState() { + return Container( + constraints: widget.constraints ?? + const BoxConstraints( + maxWidth: 300, + maxHeight: 300, + minHeight: 150, + minWidth: 200, + ), + margin: const EdgeInsets.only(bottom: Spacing.xs), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: Center( + child: CircularProgressIndicator( + color: context.conduitTheme.buttonPrimary, + strokeWidth: 2, + ), + ), + ); + } + + Widget _buildErrorState() { + return Container( + constraints: widget.constraints ?? + const BoxConstraints( + maxWidth: 300, + maxHeight: 150, + minHeight: 100, + minWidth: 200, + ), + margin: const EdgeInsets.only(bottom: Spacing.xs), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.error.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.broken_image_outlined, + color: context.conduitTheme.error, + size: 32, + ), + const SizedBox(height: Spacing.xs), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + child: Text( + _errorMessage!, + style: TextStyle( + color: context.conduitTheme.error, + fontSize: AppTypography.bodySmall, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildNetworkImage() { + final imageWidget = CachedNetworkImage( + imageUrl: _cachedImageData!, + fit: BoxFit.cover, + placeholder: (context, url) => _buildLoadingState(), + errorWidget: (context, url, error) { + _errorMessage = error.toString(); + return _buildErrorState(); + }, + ); + + return _wrapImage(imageWidget); + } + + Widget _buildBase64Image() { + try { + // Extract base64 data from data URL if needed + String actualBase64; + if (_cachedImageData!.startsWith('data:')) { + final commaIndex = _cachedImageData!.indexOf(','); + if (commaIndex != -1) { + actualBase64 = _cachedImageData!.substring(commaIndex + 1); + } else { + throw Exception('Invalid data URL format'); + } + } else { + actualBase64 = _cachedImageData!; + } + + final imageBytes = base64.decode(actualBase64); + final imageWidget = Image.memory( + imageBytes, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + _errorMessage = 'Failed to decode image'; + return _buildErrorState(); + }, + ); + + return _wrapImage(imageWidget); + } catch (e) { + _errorMessage = 'Invalid image format'; + return _buildErrorState(); + } + } + + Widget _wrapImage(Widget imageWidget) { + return Container( + constraints: widget.constraints ?? + const BoxConstraints( + maxWidth: 400, + maxHeight: 400, + ), + margin: widget.isMarkdownFormat + ? const EdgeInsets.symmetric(vertical: Spacing.sm) + : EdgeInsets.zero, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onTap ?? () => _showFullScreenImage(context), + child: Hero( + tag: 'image_${widget.attachmentId}', + child: imageWidget, + ), + ), + ), + ); + } + + void _showFullScreenImage(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => FullScreenImageViewer( + imageData: _cachedImageData!, + tag: 'image_${widget.attachmentId}', + ), + ), + ); + } +} + +class FullScreenImageViewer extends StatelessWidget { + final String imageData; + final String tag; + + const FullScreenImageViewer({ + super.key, + required this.imageData, + required this.tag, + }); + + @override + Widget build(BuildContext context) { + Widget imageWidget; + + if (imageData.startsWith('http')) { + imageWidget = CachedNetworkImage( + imageUrl: imageData, + fit: BoxFit.contain, + placeholder: (context, url) => Center( + child: CircularProgressIndicator( + color: context.conduitTheme.buttonPrimary, + ), + ), + errorWidget: (context, url, error) => Center( + child: Icon( + Icons.error_outline, + color: context.conduitTheme.error, + size: 48, + ), + ), + ); + } else { + try { + String actualBase64; + if (imageData.startsWith('data:')) { + final commaIndex = imageData.indexOf(','); + actualBase64 = imageData.substring(commaIndex + 1); + } else { + actualBase64 = imageData; + } + final imageBytes = base64.decode(actualBase64); + imageWidget = Image.memory( + imageBytes, + fit: BoxFit.contain, + ); + } catch (e) { + imageWidget = Center( + child: Icon( + Icons.error_outline, + color: context.conduitTheme.error, + size: 48, + ), + ); + } + } + + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + Center( + child: Hero( + tag: tag, + child: InteractiveViewer( + minScale: 0.5, + maxScale: 5.0, + child: imageWidget, + ), + ), + ), + Positioned( + top: MediaQuery.of(context).padding.top + 16, + right: 16, + child: IconButton( + icon: const Icon( + Icons.close, + color: Colors.white, + size: 28, + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/chat/widgets/modern_message_bubble.dart b/lib/features/chat/widgets/modern_message_bubble.dart index 705c9d0..9495392 100644 --- a/lib/features/chat/widgets/modern_message_bubble.dart +++ b/lib/features/chat/widgets/modern_message_bubble.dart @@ -1,12 +1,12 @@ -import 'dart:convert'; import 'package:flutter/material.dart'; import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; +import 'enhanced_image_attachment.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:io' show Platform; -import '../../../core/providers/app_providers.dart'; class ModernMessageBubble extends ConsumerStatefulWidget { final dynamic message; @@ -42,14 +42,6 @@ class _ModernMessageBubbleState extends ConsumerState bool _showActions = false; late AnimationController _fadeController; late AnimationController _slideController; - static const int _maxCachedImages = 24; - - // Cache for image base64 data to prevent repeated API calls - final Map _imageCache = {}; - - // Cache for rendered image widgets to prevent rebuilding during streaming - final Map _imageWidgetCache = {}; - String? _lastAttachmentIds; @override void initState() { @@ -66,202 +58,121 @@ class _ModernMessageBubbleState extends ConsumerState - Widget _buildAttachmentImages() { + Widget _buildUserAttachmentImages() { if (widget.message.attachmentIds == null || widget.message.attachmentIds!.isEmpty) { return const SizedBox.shrink(); } - final currentAttachmentIds = widget.message.attachmentIds!.join('_'); + final imageCount = widget.message.attachmentIds!.length; - // Clear cache if attachment IDs changed - if (_lastAttachmentIds != currentAttachmentIds) { - _imageWidgetCache.clear(); - _lastAttachmentIds = currentAttachmentIds; + // iMessage-style image layout + if (imageCount == 1) { + // Single image - larger display + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), + child: EnhancedImageAttachment( + attachmentId: widget.message.attachmentIds![0], + isUserMessage: true, + constraints: const BoxConstraints( + maxWidth: 280, + maxHeight: 350, + ), + ), + ), + ], + ); + } else if (imageCount == 2) { + // Two images side by side + return Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + children: widget.message.attachmentIds!.map((attachmentId) { + return Padding( + padding: EdgeInsets.only( + left: attachmentId == widget.message.attachmentIds!.first + ? 0 + : Spacing.xs, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), + child: EnhancedImageAttachment( + attachmentId: attachmentId, + isUserMessage: true, + constraints: const BoxConstraints( + maxWidth: 135, + maxHeight: 180, + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ); + } else { + // Grid layout for 3+ images + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: Container( + constraints: const BoxConstraints(maxWidth: 280), + child: Wrap( + alignment: WrapAlignment.end, + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: widget.message.attachmentIds!.map((attachmentId) { + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: EnhancedImageAttachment( + attachmentId: attachmentId, + isUserMessage: true, + constraints: BoxConstraints( + maxWidth: imageCount == 3 ? 135 : 90, + maxHeight: imageCount == 3 ? 135 : 90, + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ); } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: widget.message.attachmentIds!.map((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(); + Widget _buildAssistantAttachmentImages() { + if (widget.message.attachmentIds == null || + widget.message.attachmentIds!.isEmpty) { + return const SizedBox.shrink(); + } - return FutureBuilder( - 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, - ), - ), - ], - ), - ); - } - }, + // Assistant images - similar style but left-aligned + return Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: widget.message.attachmentIds!.map((attachmentId) { + return ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), + child: EnhancedImageAttachment( + attachmentId: attachmentId, + constraints: const BoxConstraints( + maxWidth: 300, + maxHeight: 350, + ), + ), ); - }, + }).toList(), ); } @@ -296,81 +207,84 @@ class _ModernMessageBubbleState extends ConsumerState } Widget _buildUserMessage() { - return Container( - width: double.infinity, - margin: const EdgeInsets.only( - bottom: Spacing.messagePadding, - left: Spacing.xxxl, - right: Spacing.xs, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ + final hasImages = widget.message.attachmentIds != null && + widget.message.attachmentIds!.isNotEmpty; + final hasText = widget.message.content.isNotEmpty; + + return GestureDetector( + onLongPress: () => _toggleActions(), + behavior: HitTestBehavior.translucent, + child: Container( + width: double.infinity, + margin: const EdgeInsets.only( + bottom: Spacing.sm, + left: Spacing.xxxl, + right: Spacing.xs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Display images outside and above the text bubble (iMessage style) + if (hasImages) ...[ + _buildUserAttachmentImages(), + if (hasText) const SizedBox(height: Spacing.xs), + ], + + // Display text bubble if there's text content + if (hasText) Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Flexible( - child: GestureDetector( - onLongPress: () => _toggleActions(), child: Container( padding: const EdgeInsets.symmetric( horizontal: Spacing.messagePadding, - vertical: Spacing.sm, + vertical: Spacing.xs, ), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - context.conduitTheme.chatBubbleUser.withValues( - alpha: 0.95, - ), - context.conduitTheme.chatBubbleUser, - ], - ), - borderRadius: BorderRadius.circular( - AppBorderRadius.messageBubble, - ), - border: Border.all( - color: context.conduitTheme.chatBubbleUserBorder, - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.high, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Display images if any - if (widget.message.attachmentIds != null && - widget.message.attachmentIds!.isNotEmpty) - _buildAttachmentImages(), - - // Display text content if any - if (widget.message.content.isNotEmpty) ...[ - if (widget.message.attachmentIds != null && - widget.message.attachmentIds!.isNotEmpty) - const SizedBox(height: Spacing.sm), - _buildCustomText( - widget.message.content, - context.conduitTheme.chatBubbleUserText, - ), - ], + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + context.conduitTheme.chatBubbleUser.withValues( + alpha: 0.95, + ), + context.conduitTheme.chatBubbleUser, ], ), + borderRadius: BorderRadius.circular( + AppBorderRadius.messageBubble, + ), + border: Border.all( + color: context.conduitTheme.chatBubbleUserBorder, + width: BorderWidth.regular, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: _buildCustomText( + widget.message.content, + context.conduitTheme.chatBubbleUserText, ), ), ), ], ), - - // Action buttons below the message bubble - if (_showActions) ...[ - const SizedBox(height: Spacing.sm), - _buildUserActionButtons(), - ], + + // Action buttons below the message + if (_showActions) ...[ + const SizedBox(height: Spacing.sm), + _buildUserActionButtons(), ], - ), - ) + ], + ), + ), + ) .animate() .fadeIn(duration: AnimationDuration.messageAppear) .slideX( @@ -382,103 +296,115 @@ class _ModernMessageBubbleState extends ConsumerState } Widget _buildAssistantMessage() { - return Container( - width: double.infinity, - margin: const EdgeInsets.only( - bottom: Spacing.lg, - left: Spacing.xs, - right: Spacing.xxxl, - ), - 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( - gradient: LinearGradient( - colors: [ - context.conduitTheme.buttonPrimary.withValues( - alpha: 0.9, - ), - context.conduitTheme.buttonPrimary, - ], - ), - borderRadius: BorderRadius.circular( - AppBorderRadius.small, - ), + final hasImages = widget.message.attachmentIds != null && + widget.message.attachmentIds!.isNotEmpty; + final hasContent = widget.message.content.isNotEmpty && + widget.message.content != '[TYPING_INDICATOR]'; + final showTyping = (widget.message.content.isEmpty || + widget.message.content == '[TYPING_INDICATOR]') && + widget.isStreaming; + + return GestureDetector( + onLongPress: () => _toggleActions(), + behavior: HitTestBehavior.translucent, + child: Container( + width: double.infinity, + margin: const EdgeInsets.only( + bottom: Spacing.md, + left: Spacing.xs, + right: Spacing.xxxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Simplified AI Name and Avatar + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + context.conduitTheme.buttonPrimary.withValues( + alpha: 0.9, + ), + context.conduitTheme.buttonPrimary, + ], ), - child: Icon( - Icons.auto_awesome, - color: context.conduitTheme.buttonPrimaryText, - size: 12, + borderRadius: BorderRadius.circular( + AppBorderRadius.small, ), ), - const SizedBox(width: Spacing.xs), - Text( - widget.modelName ?? 'Assistant', - style: TextStyle( - color: context.conduitTheme.textSecondary, - fontSize: AppTypography.bodySmall, - fontWeight: FontWeight.w500, - letterSpacing: 0.1, - ), + 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, + ), + ), + ], + ), + ), + + // Display images outside the bubble if any + if (hasImages) ...[ + _buildAssistantAttachmentImages(), + if (hasContent || showTyping) const SizedBox(height: Spacing.xs), + ], + + // Message Content Bubble + if (hasContent || showTyping) + Container( + padding: EdgeInsets.symmetric( + horizontal: Spacing.messagePadding, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: context.conduitTheme.chatBubbleAssistant, + borderRadius: BorderRadius.circular( + AppBorderRadius.messageBubble, + ), + border: Border.all( + color: context.conduitTheme.chatBubbleAssistantBorder, + width: BorderWidth.regular, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 3, + offset: const Offset(0, 1), ), ], ), + child: showTyping + ? _buildTypingIndicator() + : _buildCustomText( + widget.message.content, + context.conduitTheme.chatBubbleAssistantText, + ), ), - // Message Content - GestureDetector( - onLongPress: () => _toggleActions(), - child: Container( - padding: const EdgeInsets.all(Spacing.messagePadding), - decoration: BoxDecoration( - color: context.conduitTheme.chatBubbleAssistant, - borderRadius: BorderRadius.circular( - AppBorderRadius.messageBubble, - ), - border: Border.all( - color: context.conduitTheme.chatBubbleAssistantBorder, - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.low, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Check for typing indicator - show for empty content OR explicit typing indicator during streaming - if ((widget.message.content.isEmpty || - widget.message.content == '[TYPING_INDICATOR]') && - widget.isStreaming) ...[ - _buildTypingIndicator(), - ] else if (widget.message.content.isNotEmpty && - widget.message.content != '[TYPING_INDICATOR]') ...[ - _buildCustomText( - widget.message.content, - context.conduitTheme.chatBubbleAssistantText, - ), - ] else - // Fallback: show empty state for non-streaming empty messages - const SizedBox.shrink(), - ], - ), - ), - ), - - // Action buttons below the message content - if (_showActions) ...[ - const SizedBox(height: Spacing.sm), - _buildActionButtons(), - ], + // Action buttons below the message content + if (_showActions) ...[ + const SizedBox(height: Spacing.sm), + _buildActionButtons(), ], - ), - ) + ], + ), + ), + ) .animate() .fadeIn(duration: AnimationDuration.messageAppear) .slideX( @@ -491,216 +417,19 @@ class _ModernMessageBubbleState extends ConsumerState - Future _getCachedImageBase64(dynamic api, String fileId) async { - // Check cache first to prevent repeated API calls - if (_imageCache.containsKey(fileId)) { - return _imageCache[fileId]; - } - // If not in cache, get the image and cache it - final result = await _getImageBase64(api, fileId); - // Simple LRU-like eviction to bound memory - if (_imageCache.length >= _maxCachedImages) { - _imageCache.remove(_imageCache.keys.first); - } - _imageCache[fileId] = result; - return result; - } - - Future _getImageBase64(dynamic api, String fileId) async { - try { - // Check if this is already a data URL (for images) - if (fileId.startsWith('data:')) { - return fileId; - } - - // First, get file info to determine if it's an image - final fileInfo = await api.getFileInfo(fileId); - final fileName = - fileInfo['filename'] ?? - fileInfo['meta']?['name'] ?? - fileInfo['name'] ?? - fileInfo['file_name'] ?? - fileInfo['original_name'] ?? - fileInfo['original_filename'] ?? - ''; - final ext = fileName.toLowerCase().split('.').last; - - // Only process image files - if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) { - debugPrint('DEBUG: Skipping non-image file: $fileName'); - return null; - } - - // Get file content as base64 string - final fileContent = await api.getFileContent(fileId); - return fileContent; - } catch (e) { - debugPrint('DEBUG: Error getting image content for $fileId: $e'); - return null; - } - } Widget _buildCustomText(String text, [Color? textColor]) { - // Simple markdown-like parsing for efficiency - final lines = text.split('\n'); - final widgets = []; - - for (int i = 0; i < lines.length; i++) { - final line = lines[i]; - if (line.trim().isEmpty) { - if (i < lines.length - 1) { - widgets.add(const SizedBox(height: Spacing.sm)); - } - continue; - } - - // Parse basic markdown - Widget textWidget = _parseMarkdownLine(line, textColor); - - if (i < lines.length - 1) { - widgets.add(textWidget); - widgets.add(const SizedBox(height: Spacing.xs)); - } else { - widgets.add(textWidget); - } - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: widgets, + // Use the new markdown widget for rich text rendering + return StreamingMarkdownWidget( + staticContent: text, + isStreaming: widget.isStreaming, ); } - Widget _parseMarkdownLine(String line, [Color? textColor]) { - // Handle code blocks - if (line.startsWith('```')) { - return Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.xs), - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: context.conduitTheme.surfaceBackground.withValues( - alpha: Alpha.badgeBackground, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.sm), - border: Border.all( - color: context.conduitTheme.textPrimary.withValues( - alpha: Alpha.subtle, - ), - width: BorderWidth.regular, - ), - ), - child: Text( - line.substring(3), - style: AppTypography.chatCodeStyle.copyWith( - color: textColor ?? context.conduitTheme.textSecondary, - ), - ), - ) - .animate() - .fadeIn(duration: AnimationDuration.microInteraction) - .slideX( - begin: 0.1, - end: 0, - duration: AnimationDuration.microInteraction, - ); - } - // Handle headers - if (line.startsWith('#')) { - int level = 0; - while (level < line.length && line[level] == '#') { - level++; - } - final fontSize = AppTypography.headlineMedium - (level * 2); - return Text( - line.substring(level).trim(), - style: AppTypography.headlineMediumStyle.copyWith( - color: textColor ?? context.conduitTheme.textPrimary, - fontSize: fontSize.toDouble(), - ), - ) - .animate() - .fadeIn(duration: AnimationDuration.microInteraction) - .slideX( - begin: 0.1, - end: 0, - duration: AnimationDuration.microInteraction, - ); - } - // Handle inline code - if (line.contains('`')) { - final parts = line.split('`'); - final widgets = []; - for (int i = 0; i < parts.length; i++) { - if (parts[i].isNotEmpty) { - if (i % 2 == 1) { - // Inline code - widgets.add( - Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.xs + Spacing.xxs, - vertical: Spacing.xxs, - ), - decoration: BoxDecoration( - color: context.conduitTheme.textPrimary.withValues( - alpha: Alpha.badgeBackground, - ), - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - child: Text( - parts[i], - style: AppTypography.chatCodeStyle.copyWith( - color: textColor ?? context.conduitTheme.textSecondary, - ), - ), - ), - ); - } else { - // Regular text - widgets.add( - Text( - parts[i], - style: AppTypography.chatMessageStyle.copyWith( - color: textColor ?? context.conduitTheme.textPrimary, - ), - ), - ); - } - } - } - - return Wrap( - crossAxisAlignment: WrapCrossAlignment.start, - children: widgets, - ) - .animate() - .fadeIn(duration: AnimationDuration.microInteraction) - .slideX( - begin: 0.1, - end: 0, - duration: AnimationDuration.microInteraction, - ); - } - - // Regular text - return Text( - line, - style: AppTypography.chatMessageStyle.copyWith( - color: textColor ?? context.conduitTheme.textPrimary, - letterSpacing: 0.1, - ), - ) - .animate() - .fadeIn(duration: AnimationDuration.microInteraction) - .slideX( - begin: 0.1, - end: 0, - duration: AnimationDuration.microInteraction, - ); - } Widget _buildTypingIndicator() { return Consumer( diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 0fb0473..21472f6 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:markdown_widget/markdown_widget.dart'; import 'package:flutter_highlight/themes/atom-one-dark.dart'; @@ -41,7 +42,7 @@ class ConduitMarkdownConfig { // Link config LinkConfig( style: TextStyle( - color: AppTheme.brandPrimary, + color: theme.buttonPrimary, decoration: TextDecoration.underline, ), onTap: (url) async { @@ -51,30 +52,60 @@ class ConduitMarkdownConfig { }, ), - // Image config - optimized for mobile + // Image config - optimized for mobile with support for base64 and network images ImgConfig( - builder: (url, attributes) => CachedNetworkImage( - imageUrl: url, - placeholder: (context, url) => Container( - height: 200, - color: theme.surfaceBackground, - child: Center( - child: CircularProgressIndicator( - color: AppTheme.brandPrimary, + builder: (url, attributes) { + // Check if it's a base64 data URL + if (url.startsWith('data:')) { + return _buildBase64Image(url, theme); + } + // Network image + return CachedNetworkImage( + imageUrl: url, + placeholder: (context, url) => Container( + height: 200, + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Center( + child: CircularProgressIndicator( + color: theme.loadingIndicator, + strokeWidth: 2, + ), ), ), - ), - errorWidget: (context, url, error) => Container( - height: 100, - color: theme.surfaceBackground, - child: Center( - child: Icon( - Icons.broken_image, - color: theme.iconSecondary, + errorWidget: (context, url, error) => Container( + height: 100, + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: theme.error.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.broken_image_outlined, + color: theme.error, + size: 32, + ), + const SizedBox(height: Spacing.xs), + Text( + 'Failed to load image', + style: TextStyle( + color: theme.error, + fontSize: 12, + ), + ), + ], ), ), - ), - ), + ); + }, ), // Table config - mobile responsive @@ -89,6 +120,7 @@ class ConduitMarkdownConfig { PConfig( textStyle: AppTypography.chatMessageStyle.copyWith( color: theme.textPrimary, + height: 1.3, ), ), @@ -122,6 +154,82 @@ class ConduitMarkdownConfig { ], ); } + + static Widget _buildBase64Image(String dataUrl, ConduitThemeExtension theme) { + try { + // Extract base64 part from data URL + final commaIndex = dataUrl.indexOf(','); + if (commaIndex == -1) { + throw Exception('Invalid data URL format'); + } + + final base64String = dataUrl.substring(commaIndex + 1); + final imageBytes = base64.decode(base64String); + + return Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + constraints: const BoxConstraints( + maxWidth: 500, + maxHeight: 500, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Image.memory( + imageBytes, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 100, + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: theme.error.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + color: theme.error, + size: 32, + ), + const SizedBox(height: Spacing.xs), + Text( + 'Invalid image data', + style: TextStyle( + color: theme.error, + fontSize: 12, + ), + ), + ], + ), + ); + }, + ), + ), + ); + } catch (e) { + return Container( + height: 100, + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Center( + child: Text( + 'Invalid image format', + style: TextStyle( + color: theme.error, + fontSize: 12, + ), + ), + ), + ); + } + } } /// Custom wrapper for code blocks with copy functionality @@ -148,7 +256,7 @@ class CodeBlockWrapper extends StatelessWidget { top: 8, right: 8, child: Material( - color: Colors.transparent, + color: theme.surfaceBackground.withValues(alpha: 0.0), child: InkWell( borderRadius: BorderRadius.circular(AppBorderRadius.sm), onTap: () { diff --git a/lib/shared/widgets/markdown/streaming_markdown_widget.dart b/lib/shared/widgets/markdown/streaming_markdown_widget.dart index 1cae9d4..f4b01c2 100644 --- a/lib/shared/widgets/markdown/streaming_markdown_widget.dart +++ b/lib/shared/widgets/markdown/streaming_markdown_widget.dart @@ -122,13 +122,10 @@ class _StreamingMarkdownWidgetState extends State { if (widget.isStreaming && _renderedContent.isNotEmpty) { // Use MarkdownBlock for streaming - it's optimized for live updates - return Container( - padding: widget.padding, - child: MarkdownBlock( - data: _renderedContent, - config: config, - selectable: true, - ), + return MarkdownBlock( + data: _renderedContent, + config: config, + selectable: true, ); } else { // Use MarkdownWidget for completed messages @@ -139,7 +136,7 @@ class _StreamingMarkdownWidgetState extends State { selectable: true, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - padding: widget.padding, + padding: EdgeInsets.zero, ); } }