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 '../../../core/utils/reasoning_parser.dart';
|
||||||
import 'enhanced_image_attachment.dart';
|
import 'enhanced_image_attachment.dart';
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
|
import 'enhanced_attachment.dart';
|
||||||
|
|
||||||
class AssistantMessageWidget extends ConsumerStatefulWidget {
|
class AssistantMessageWidget extends ConsumerStatefulWidget {
|
||||||
final dynamic message;
|
final dynamic message;
|
||||||
@@ -281,10 +282,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Display attachment images if any (for user uploaded images)
|
// Display attachments (images use EnhancedImageAttachment; non-images use card)
|
||||||
if (widget.message.attachmentIds != null &&
|
if (widget.message.attachmentIds != null &&
|
||||||
widget.message.attachmentIds!.isNotEmpty) ...[
|
widget.message.attachmentIds!.isNotEmpty) ...[
|
||||||
_buildAttachmentImages(),
|
_buildAttachmentItems(),
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -371,7 +372,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAttachmentImages() {
|
Widget _buildAttachmentItems() {
|
||||||
if (widget.message.attachmentIds == null ||
|
if (widget.message.attachmentIds == null ||
|
||||||
widget.message.attachmentIds!.isEmpty) {
|
widget.message.attachmentIds!.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -386,28 +387,27 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
switchInCurve: Curves.easeInOut,
|
switchInCurve: Curves.easeInOut,
|
||||||
child: imageCount == 1
|
child: imageCount == 1
|
||||||
? Container(
|
? Container(
|
||||||
key: ValueKey('single_image_${widget.message.attachmentIds![0]}'),
|
key: ValueKey('single_item_${widget.message.attachmentIds![0]}'),
|
||||||
child: EnhancedImageAttachment(
|
child: EnhancedAttachment(
|
||||||
attachmentId: widget.message.attachmentIds![0],
|
attachmentId: widget.message.attachmentIds![0],
|
||||||
isMarkdownFormat: true,
|
isMarkdownFormat: true,
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
maxWidth: 500,
|
maxWidth: 500,
|
||||||
maxHeight: 400,
|
maxHeight: 400,
|
||||||
),
|
),
|
||||||
disableAnimation:
|
disableAnimation: widget.isStreaming,
|
||||||
widget.isStreaming, // Disable animation during streaming
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Wrap(
|
: Wrap(
|
||||||
key: ValueKey(
|
key: ValueKey(
|
||||||
'multi_images_${widget.message.attachmentIds!.join('_')}',
|
'multi_items_${widget.message.attachmentIds!.join('_')}',
|
||||||
),
|
),
|
||||||
spacing: Spacing.sm,
|
spacing: Spacing.sm,
|
||||||
runSpacing: Spacing.sm,
|
runSpacing: Spacing.sm,
|
||||||
children: widget.message.attachmentIds!.map<Widget>((
|
children: widget.message.attachmentIds!.map<Widget>((
|
||||||
attachmentId,
|
attachmentId,
|
||||||
) {
|
) {
|
||||||
return EnhancedImageAttachment(
|
return EnhancedAttachment(
|
||||||
key: ValueKey('attachment_$attachmentId'),
|
key: ValueKey('attachment_$attachmentId'),
|
||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
isMarkdownFormat: true,
|
isMarkdownFormat: true,
|
||||||
@@ -415,8 +415,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
maxWidth: imageCount == 2 ? 245 : 160,
|
maxWidth: imageCount == 2 ? 245 : 160,
|
||||||
maxHeight: imageCount == 2 ? 245 : 160,
|
maxHeight: imageCount == 2 ? 245 : 160,
|
||||||
),
|
),
|
||||||
disableAnimation:
|
disableAnimation: widget.isStreaming,
|
||||||
widget.isStreaming, // Disable animation during streaming
|
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).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 'package:flutter/material.dart';
|
||||||
import '../../../shared/theme/theme_extensions.dart';
|
import '../../../shared/theme/theme_extensions.dart';
|
||||||
import 'enhanced_image_attachment.dart';
|
import 'enhanced_image_attachment.dart';
|
||||||
|
import 'enhanced_attachment.dart';
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -264,7 +265,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
AppBorderRadius.messageBubble,
|
AppBorderRadius.messageBubble,
|
||||||
),
|
),
|
||||||
child: EnhancedImageAttachment(
|
child: EnhancedAttachment(
|
||||||
attachmentId: widget.message.attachmentIds![0],
|
attachmentId: widget.message.attachmentIds![0],
|
||||||
isUserMessage: true,
|
isUserMessage: true,
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
@@ -313,7 +314,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
AppBorderRadius.messageBubble,
|
AppBorderRadius.messageBubble,
|
||||||
),
|
),
|
||||||
child: EnhancedImageAttachment(
|
child: EnhancedAttachment(
|
||||||
key: ValueKey('user_attachment_$attachmentId'),
|
key: ValueKey('user_attachment_$attachmentId'),
|
||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
isUserMessage: true,
|
isUserMessage: true,
|
||||||
@@ -360,7 +361,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
child: EnhancedImageAttachment(
|
child: EnhancedAttachment(
|
||||||
key: ValueKey('user_grid_attachment_$attachmentId'),
|
key: ValueKey('user_grid_attachment_$attachmentId'),
|
||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
isUserMessage: true,
|
isUserMessage: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user