refactor: replace EnhancedImageAttachment with EnhancedAttachment for improved attachment handling in chat messages
This commit is contained in:
@@ -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<AssistantMessageWidget>
|
||||
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<AssistantMessageWidget>
|
||||
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<AssistantMessageWidget>
|
||||
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<Widget>((
|
||||
attachmentId,
|
||||
) {
|
||||
return EnhancedImageAttachment(
|
||||
return EnhancedAttachment(
|
||||
key: ValueKey('attachment_$attachmentId'),
|
||||
attachmentId: attachmentId,
|
||||
isMarkdownFormat: true,
|
||||
@@ -415,8 +415,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
maxWidth: imageCount == 2 ? 245 : 160,
|
||||
maxHeight: imageCount == 2 ? 245 : 160,
|
||||
),
|
||||
disableAnimation:
|
||||
widget.isStreaming, // Disable animation during streaming
|
||||
disableAnimation: widget.isStreaming,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
280
lib/features/chat/widgets/enhanced_attachment.dart
Normal file
280
lib/features/chat/widgets/enhanced_attachment.dart
Normal file
@@ -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<EnhancedAttachment> createState() => _EnhancedAttachmentState();
|
||||
}
|
||||
|
||||
class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
|
||||
Map<String, dynamic>? _fileInfo;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String? _localFilePath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_resolveType();
|
||||
}
|
||||
|
||||
Future<void> _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<String, dynamic>? 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<String?> _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<void> _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';
|
||||
}
|
||||
}
|
||||
@@ -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<UserMessageBubble>
|
||||
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<UserMessageBubble>
|
||||
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<UserMessageBubble>
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
child: EnhancedImageAttachment(
|
||||
child: EnhancedAttachment(
|
||||
key: ValueKey('user_grid_attachment_$attachmentId'),
|
||||
attachmentId: attachmentId,
|
||||
isUserMessage: true,
|
||||
|
||||
Reference in New Issue
Block a user