refactor: replace EnhancedImageAttachment with EnhancedAttachment for improved attachment handling in chat messages

This commit is contained in:
cogwheel0
2025-08-26 13:31:47 +05:30
parent b174de7701
commit 807dc01e8e
3 changed files with 294 additions and 14 deletions

View File

@@ -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(),
),

View 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';
}
}

View File

@@ -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,