diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index dfc284d..2bb93fc 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -9,6 +9,7 @@ import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; import '../../../core/utils/reasoning_parser.dart'; import 'enhanced_image_attachment.dart'; import 'package:conduit/l10n/app_localizations.dart'; +import 'enhanced_attachment.dart'; class AssistantMessageWidget extends ConsumerStatefulWidget { final dynamic message; @@ -281,10 +282,10 @@ class _AssistantMessageWidgetState extends ConsumerState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Display attachment images if any (for user uploaded images) + // Display attachments (images use EnhancedImageAttachment; non-images use card) if (widget.message.attachmentIds != null && widget.message.attachmentIds!.isNotEmpty) ...[ - _buildAttachmentImages(), + _buildAttachmentItems(), const SizedBox(height: Spacing.md), ], @@ -371,7 +372,7 @@ class _AssistantMessageWidgetState extends ConsumerState return content; } - Widget _buildAttachmentImages() { + Widget _buildAttachmentItems() { if (widget.message.attachmentIds == null || widget.message.attachmentIds!.isEmpty) { return const SizedBox.shrink(); @@ -386,28 +387,27 @@ class _AssistantMessageWidgetState extends ConsumerState switchInCurve: Curves.easeInOut, child: imageCount == 1 ? Container( - key: ValueKey('single_image_${widget.message.attachmentIds![0]}'), - child: EnhancedImageAttachment( + key: ValueKey('single_item_${widget.message.attachmentIds![0]}'), + child: EnhancedAttachment( attachmentId: widget.message.attachmentIds![0], isMarkdownFormat: true, constraints: const BoxConstraints( maxWidth: 500, maxHeight: 400, ), - disableAnimation: - widget.isStreaming, // Disable animation during streaming + disableAnimation: widget.isStreaming, ), ) : Wrap( key: ValueKey( - 'multi_images_${widget.message.attachmentIds!.join('_')}', + 'multi_items_${widget.message.attachmentIds!.join('_')}', ), spacing: Spacing.sm, runSpacing: Spacing.sm, children: widget.message.attachmentIds!.map(( attachmentId, ) { - return EnhancedImageAttachment( + return EnhancedAttachment( key: ValueKey('attachment_$attachmentId'), attachmentId: attachmentId, isMarkdownFormat: true, @@ -415,8 +415,7 @@ class _AssistantMessageWidgetState extends ConsumerState maxWidth: imageCount == 2 ? 245 : 160, maxHeight: imageCount == 2 ? 245 : 160, ), - disableAnimation: - widget.isStreaming, // Disable animation during streaming + disableAnimation: widget.isStreaming, ); }).toList(), ), diff --git a/lib/features/chat/widgets/enhanced_attachment.dart b/lib/features/chat/widgets/enhanced_attachment.dart new file mode 100644 index 0000000..073123b --- /dev/null +++ b/lib/features/chat/widgets/enhanced_attachment.dart @@ -0,0 +1,280 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../core/services/api_service.dart'; +import 'enhanced_image_attachment.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; +import 'dart:convert'; + +class EnhancedAttachment extends ConsumerStatefulWidget { + final String attachmentId; + final bool isMarkdownFormat; + final BoxConstraints? constraints; + final bool isUserMessage; + final bool disableAnimation; + + const EnhancedAttachment({ + super.key, + required this.attachmentId, + this.isMarkdownFormat = false, + this.constraints, + this.isUserMessage = false, + this.disableAnimation = false, + }); + + @override + ConsumerState createState() => _EnhancedAttachmentState(); +} + +class _EnhancedAttachmentState extends ConsumerState { + Map? _fileInfo; + bool _isLoading = true; + String? _error; + String? _localFilePath; + + @override + void initState() { + super.initState(); + _resolveType(); + } + + Future _resolveType() async { + try { + // Data URL for images – short-circuit to image widget + if (widget.attachmentId.startsWith('data:image/')) { + setState(() { + _isLoading = false; + _fileInfo = {'mime': 'image/inline'}; + }); + return; + } + + final api = ref.read(apiServiceProvider); + if (api is! ApiService) { + setState(() { + _isLoading = false; + _error = 'Service unavailable'; + }); + return; + } + + final info = await api.getFileInfo(widget.attachmentId); + setState(() { + _fileInfo = info; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = 'Failed to load attachment'; + _isLoading = false; + }); + } + } + + bool _isImageFile(Map? info) { + if (info == null) return false; + final mime = (info['content_type'] ?? info['mime'] ?? '') + .toString() + .toLowerCase(); + if (mime.startsWith('image/')) return true; + final name = (info['filename'] ?? info['name'] ?? '') + .toString() + .toLowerCase(); + final ext = name.split('.').length > 1 ? name.split('.').last : ''; + return ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext); + } + + Future _ensureLocalFile() async { + if (_localFilePath != null && await File(_localFilePath!).exists()) { + return _localFilePath; + } + try { + final api = ref.read(apiServiceProvider); + if (api is! ApiService) return null; + + final content = await api.getFileContent(widget.attachmentId); + final filename = (_fileInfo?['filename'] ?? _fileInfo?['name'] ?? 'file') + .toString(); + final dir = await getTemporaryDirectory(); + final filePath = '${dir.path}/$filename'; + + try { + if (content.length > 128 && + RegExp( + r'^[A-Za-z0-9+/=\r\n]+$', + ).hasMatch(content.replaceAll('\n', ''))) { + final bytes = base64Decode(content.replaceAll('\n', '')); + await File(filePath).writeAsBytes(bytes, flush: true); + } else { + await File(filePath).writeAsString(content, flush: true); + } + } catch (_) { + await File(filePath).writeAsString(content, flush: true); + } + + _localFilePath = filePath; + return _localFilePath; + } catch (e) { + setState(() { + _error = 'Failed to prepare file'; + }); + return null; + } + } + + Future _shareFile() async { + final path = await _ensureLocalFile(); + if (path == null) return; + final filename = (_fileInfo?['filename'] ?? _fileInfo?['name'] ?? 'file') + .toString(); + await Share.shareXFiles([XFile(path, name: filename)]); + } + + String _fileIconFor(String filename) { + final lower = filename.toLowerCase(); + String ext = ''; + final parts = lower.split('.'); + if (parts.length > 1) ext = parts.last; + if (['pdf', 'doc', 'docx'].contains(ext)) return '📄'; + if (['xls', 'xlsx'].contains(ext)) return '📊'; + if (['ppt', 'pptx'].contains(ext)) return '📊'; + if (['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) return '🖼️'; + if (['js', 'ts', 'py', 'dart', 'java', 'cpp'].contains(ext)) return '💻'; + if (['html', 'css', 'json', 'xml'].contains(ext)) return '🌐'; + if (['zip', 'rar', '7z', 'tar', 'gz'].contains(ext)) return '📦'; + if (['mp3', 'wav', 'flac', 'm4a'].contains(ext)) return '🎵'; + if (['mp4', 'avi', 'mov', 'mkv'].contains(ext)) return '🎬'; + return '📎'; + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Container( + width: widget.constraints?.maxWidth ?? 160, + height: 84, + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.textPrimary.withValues(alpha: 0.1), + width: BorderWidth.regular, + ), + ), + ); + } + + if (_error != null) { + return Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.error.withValues(alpha: 0.3), + width: BorderWidth.regular, + ), + ), + child: Text( + _error!, + style: TextStyle( + color: context.conduitTheme.error, + fontSize: AppTypography.labelMedium, + ), + ), + ); + } + + // Image: delegate to existing image widget for consistency + if (_isImageFile(_fileInfo)) { + return EnhancedImageAttachment( + attachmentId: widget.attachmentId, + isMarkdownFormat: widget.isMarkdownFormat, + constraints: widget.constraints, + isUserMessage: widget.isUserMessage, + disableAnimation: widget.disableAnimation, + ); + } + + final filename = (_fileInfo?['filename'] ?? _fileInfo?['name'] ?? 'File') + .toString(); + final size = _fileInfo?['size']; + final sizeLabel = size is num ? _formatSize(size.toInt()) : null; + + final card = Container( + constraints: widget.constraints, + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: context.conduitTheme.textPrimary.withValues(alpha: 0.12), + width: BorderWidth.regular, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _fileIconFor(filename), + style: const TextStyle(fontSize: AppTypography.headlineLarge), + ), + const SizedBox(width: Spacing.sm), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + filename, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context.conduitTheme.textPrimary, + fontSize: AppTypography.labelLarge, + fontWeight: FontWeight.w600, + ), + ), + if (sizeLabel != null) + Text( + sizeLabel, + style: TextStyle( + color: context.conduitTheme.textSecondary.withValues( + alpha: 0.7, + ), + fontSize: AppTypography.labelMedium, + ), + ), + ], + ), + ), + ], + ), + ); + + return InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + onTap: () async { + await HapticFeedback.mediumImpact(); + await _shareFile(); + }, + child: card, + ); + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} diff --git a/lib/features/chat/widgets/user_message_bubble.dart b/lib/features/chat/widgets/user_message_bubble.dart index 3a885fd..4ce768a 100644 --- a/lib/features/chat/widgets/user_message_bubble.dart +++ b/lib/features/chat/widgets/user_message_bubble.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../shared/theme/theme_extensions.dart'; import 'enhanced_image_attachment.dart'; +import 'enhanced_attachment.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -264,7 +265,7 @@ class _UserMessageBubbleState extends ConsumerState borderRadius: BorderRadius.circular( AppBorderRadius.messageBubble, ), - child: EnhancedImageAttachment( + child: EnhancedAttachment( attachmentId: widget.message.attachmentIds![0], isUserMessage: true, constraints: const BoxConstraints( @@ -313,7 +314,7 @@ class _UserMessageBubbleState extends ConsumerState borderRadius: BorderRadius.circular( AppBorderRadius.messageBubble, ), - child: EnhancedImageAttachment( + child: EnhancedAttachment( key: ValueKey('user_attachment_$attachmentId'), attachmentId: attachmentId, isUserMessage: true, @@ -360,7 +361,7 @@ class _UserMessageBubbleState extends ConsumerState ), child: ClipRRect( borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: EnhancedImageAttachment( + child: EnhancedAttachment( key: ValueKey('user_grid_attachment_$attachmentId'), attachmentId: attachmentId, isUserMessage: true,