Merge pull request #345 from cogwheel0/image-attachment-loading-improvements

feat(image): Improve image attachment loading and error handling
This commit is contained in:
cogwheel
2026-01-13 23:58:58 +08:00
committed by GitHub
6 changed files with 185 additions and 51 deletions

View File

@@ -280,6 +280,9 @@ Map<String, dynamic>? _parseSiblingAsVersion(
}; };
if (entry['name'] != null) fileMap['name'] = entry['name']; if (entry['name'] != null) fileMap['name'] = entry['name'];
if (entry['size'] != null) fileMap['size'] = entry['size']; if (entry['size'] != null) fileMap['size'] = entry['size'];
if (entry['content_type'] != null) {
fileMap['content_type'] = entry['content_type'];
}
allFiles.add(fileMap); allFiles.add(fileMap);
} }
} }
@@ -448,6 +451,9 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
}; };
if (entry['name'] != null) fileMap['name'] = entry['name']; if (entry['name'] != null) fileMap['name'] = entry['name'];
if (entry['size'] != null) fileMap['size'] = entry['size']; if (entry['size'] != null) fileMap['size'] = entry['size'];
if (entry['content_type'] != null) {
fileMap['content_type'] = entry['content_type'];
}
final headers = _coerceStringMap(entry['headers']); final headers = _coerceStringMap(entry['headers']);
if (headers != null && headers.isNotEmpty) { if (headers != null && headers.isNotEmpty) {
fileMap['headers'] = headers; fileMap['headers'] = headers;
@@ -455,12 +461,22 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
allFiles.add(fileMap); allFiles.add(fileMap);
final url = entry['url'].toString(); final url = entry['url'].toString();
// Handle both URL formats: /api/v1/files/{id} and /api/v1/files/{id}/content // Handle all URL formats:
// 1. /api/v1/files/{id} and /api/v1/files/{id}/content (old format)
// 2. Just a file ID like "abc-123-def" (new OpenWebUI format)
final match = RegExp( final match = RegExp(
r'/api/v1/files/([^/]+)(?:/content)?$', r'/api/v1/files/([^/]+)(?:/content)?$',
).firstMatch(url); ).firstMatch(url);
if (match != null) { if (match != null) {
attachments.add(match.group(1)!); attachments.add(match.group(1)!);
} else if (!url.startsWith('data:') &&
!url.startsWith('http') &&
!url.startsWith('/')) {
// New format: URL is just a bare file ID (UUID-like)
// Validate it looks like a reasonable ID (not an empty string)
if (url.isNotEmpty) {
attachments.add(url);
}
} }
} }
} }

View File

@@ -1092,7 +1092,8 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
allFiles.add({ allFiles.add({
'type': 'file', 'type': 'file',
'id': attachmentId, 'id': attachmentId,
'url': '/api/v1/files/$attachmentId', // OpenWebUI now stores just the file ID, not the full URL path
'url': attachmentId,
'name': fileName, 'name': fileName,
if (fileSize != null) 'size': fileSize, if (fileSize != null) 'size': fileSize,
}); });
@@ -1801,7 +1802,9 @@ Future<void> _sendMessageInternal(
'type': isImage ? 'image' : 'file', 'type': isImage ? 'image' : 'file',
'id': fileId, 'id': fileId,
'name': fileName, 'name': fileName,
'url': '/api/v1/files/$fileId', // Full URL for conversation parsing compatibility // OpenWebUI now stores just the file ID, not the full URL path
// The frontend resolves it when displaying
'url': fileId,
if (fileSize != null) 'size': fileSize, if (fileSize != null) 'size': fileSize,
if (collectionName != null) 'collection_name': collectionName, if (collectionName != null) 'collection_name': collectionName,
if (contentType.isNotEmpty) 'content_type': contentType, if (contentType.isNotEmpty) 'content_type': contentType,
@@ -1811,7 +1814,7 @@ Future<void> _sendMessageInternal(
'type': 'file', 'type': 'file',
'id': fileId, 'id': fileId,
'name': 'file', 'name': 'file',
'url': '/api/v1/files/$fileId', 'url': fileId,
}; };
} }
}); });

View File

@@ -0,0 +1,22 @@
// Utility functions for handling file data in chat messages.
// Used by both user and assistant message widgets.
/// Checks if a file map represents an image.
/// Matches OpenWebUI behavior: type === 'image' OR content_type starts with 'image/'
bool isImageFile(dynamic file) {
if (file is! Map) return false;
if (file['type'] == 'image') return true;
final contentType = file['content_type']?.toString() ?? '';
return contentType.startsWith('image/');
}
/// Extracts the file URL or ID from a file map.
/// OpenWebUI stores either a full URL, data URL, or just the file ID.
///
/// Returns the URL/ID string, or null if the file has no valid URL.
String? getFileUrl(dynamic file) {
if (file is! Map) return null;
final url = file['url'];
if (url == null) return null;
return url.toString();
}

View File

@@ -27,6 +27,7 @@ import 'sources/openwebui_sources.dart';
import '../providers/assistant_response_builder_provider.dart'; import '../providers/assistant_response_builder_provider.dart';
import '../../../core/services/worker_manager.dart'; import '../../../core/services/worker_manager.dart';
import 'streaming_status_widget.dart'; import 'streaming_status_widget.dart';
import '../utils/file_utils.dart';
// Pre-compiled regex patterns for image processing (performance optimization) // Pre-compiled regex patterns for image processing (performance optimization)
final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*'); final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*');
@@ -1119,12 +1120,9 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
final allFiles = filesArray; final allFiles = filesArray;
// Separate images and non-image files // Separate images and non-image files
final imageFiles = allFiles // Match OpenWebUI: type === 'image' OR content_type starts with 'image/'
.where((file) => file['type'] == 'image') final imageFiles = allFiles.where(isImageFile).toList();
.toList(); final nonImageFiles = allFiles.where((file) => !isImageFile(file)).toList();
final nonImageFiles = allFiles
.where((file) => file['type'] != 'image')
.toList();
final widgets = <Widget>[]; final widgets = <Widget>[];
@@ -1164,7 +1162,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
key: ValueKey('file_single_${imageFiles[0]['url']}'), key: ValueKey('file_single_${imageFiles[0]['url']}'),
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final imageUrl = imageFiles[0]['url'] as String?; final imageUrl = getFileUrl(imageFiles[0]);
if (imageUrl == null) return const SizedBox.shrink(); if (imageUrl == null) return const SizedBox.shrink();
return EnhancedImageAttachment( return EnhancedImageAttachment(
@@ -1189,7 +1187,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
spacing: Spacing.sm, spacing: Spacing.sm,
runSpacing: Spacing.sm, runSpacing: Spacing.sm,
children: imageFiles.map<Widget>((file) { children: imageFiles.map<Widget>((file) {
final imageUrl = file['url'] as String?; final imageUrl = getFileUrl(file);
if (imageUrl == null) return const SizedBox.shrink(); if (imageUrl == null) return const SizedBox.shrink();
return EnhancedImageAttachment( return EnhancedImageAttachment(
@@ -1232,12 +1230,13 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
spacing: Spacing.sm, spacing: Spacing.sm,
runSpacing: Spacing.sm, runSpacing: Spacing.sm,
children: nonImageFiles.map<Widget>((file) { children: nonImageFiles.map<Widget>((file) {
final fileUrl = file['url'] as String?; final fileUrl = getFileUrl(file);
if (fileUrl == null) return const SizedBox.shrink(); if (fileUrl == null) return const SizedBox.shrink();
// Extract file ID from URL - handle both formats: // Extract file ID from URL - handle formats:
// /api/v1/files/{id} and /api/v1/files/{id}/content // - Bare file ID (new OpenWebUI format): "abc-123-def"
// - /api/v1/files/{id} (legacy format)
// - /api/v1/files/{id}/content (legacy format)
String attachmentId = fileUrl; String attachmentId = fileUrl;
if (fileUrl.contains('/api/v1/files/')) { if (fileUrl.contains('/api/v1/files/')) {
final fileIdMatch = _fileIdPattern.firstMatch(fileUrl); final fileIdMatch = _fileIdPattern.firstMatch(fileUrl);

View File

@@ -30,6 +30,8 @@ final _base64WhitespacePattern = RegExp(r'\s');
/// Call this with the server file ID and image bytes after successful upload. /// Call this with the server file ID and image bytes after successful upload.
void preCacheImageBytes(String fileId, Uint8List bytes) { void preCacheImageBytes(String fileId, Uint8List bytes) {
if (fileId.isEmpty || bytes.isEmpty) return; if (fileId.isEmpty || bytes.isEmpty) return;
// Clear any previous error state for this file
_globalErrorStates.remove(fileId);
_globalImageBytesCache[fileId] = bytes; _globalImageBytesCache[fileId] = bytes;
_globalLoadingStates[fileId] = false; _globalLoadingStates[fileId] = false;
// Detect SVG // Detect SVG
@@ -132,8 +134,8 @@ class _EnhancedImageAttachmentState
String? _errorMessage; String? _errorMessage;
bool _isDecoding = false; bool _isDecoding = false;
bool _isSvg = false; bool _isSvg = false;
late final String _heroTag; late String _heroTag;
// Removed unused animation and state flags bool _hasAttemptedLoad = false;
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -150,12 +152,40 @@ class _EnhancedImageAttachmentState
}); });
} }
@override
void didUpdateWidget(covariant EnhancedImageAttachment oldWidget) {
super.didUpdateWidget(oldWidget);
// If the attachment ID changed, reload the image
if (oldWidget.attachmentId != widget.attachmentId) {
_heroTag = 'image_${widget.attachmentId}_${identityHashCode(this)}';
// Reset local state with setState for immediate visual feedback
setState(() {
_cachedImageData = null;
_cachedBytes = null;
_hasAttemptedLoad = false;
_isLoading = true;
_errorMessage = null;
_isDecoding = false;
_isSvg = false;
});
// Load the new image
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_loadImage();
});
}
}
@override @override
void dispose() { void dispose() {
super.dispose(); super.dispose();
} }
Future<void> _loadImage() async { Future<void> _loadImage() async {
// Prevent duplicate loads
if (_hasAttemptedLoad) return;
_hasAttemptedLoad = true;
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
// Check bytes cache first (populated during upload for instant display) // Check bytes cache first (populated during upload for instant display)
@@ -172,6 +202,8 @@ class _EnhancedImageAttachmentState
return; return;
} }
// Check for cached errors - if image previously failed, show error immediately
// Note: preCacheImageBytes() clears errors when upload completes successfully
final cachedError = _globalErrorStates[widget.attachmentId]; final cachedError = _globalErrorStates[widget.attachmentId];
if (cachedError != null) { if (cachedError != null) {
if (mounted) { if (mounted) {
@@ -407,8 +439,16 @@ class _EnhancedImageAttachmentState
return _buildErrorState(); return _buildErrorState();
} }
if (_cachedImageData == null) { if (_cachedImageData == null && _cachedBytes == null) {
return const SizedBox.shrink(); // No data available - this shouldn't happen in normal flow since
// _loadImage always sets either data, bytes, or error before completing.
// Show error state rather than attempting reload from build().
return _buildErrorState();
}
// If we have bytes but no cached data string, use bytes directly
if (_cachedImageData == null && _cachedBytes != null) {
return _isSvg ? _buildBase64Svg() : _buildBase64Image();
} }
// Handle different image data formats // Handle different image data formats
@@ -692,11 +732,15 @@ class _EnhancedImageAttachmentState
} }
void _showFullScreenImage(BuildContext context) { void _showFullScreenImage(BuildContext context) {
// Handle both data URL string and raw bytes cases
if (_cachedImageData == null && _cachedBytes == null) return;
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
fullscreenDialog: true, fullscreenDialog: true,
builder: (context) => FullScreenImageViewer( builder: (context) => FullScreenImageViewer(
imageData: _cachedImageData!, imageData: _cachedImageData,
imageBytes: _cachedBytes,
tag: _heroTag, tag: _heroTag,
isSvg: _isSvg, isSvg: _isSvg,
customHeaders: widget.httpHeaders, customHeaders: widget.httpHeaders,
@@ -707,31 +751,56 @@ class _EnhancedImageAttachmentState
} }
class FullScreenImageViewer extends ConsumerWidget { class FullScreenImageViewer extends ConsumerWidget {
final String imageData; /// Image data as a URL (http://) or data URL (data:image/...) or base64 string.
/// Either this or [imageBytes] must be provided.
final String? imageData;
/// Raw image bytes. Used when [imageData] is null.
final Uint8List? imageBytes;
final String tag; final String tag;
final bool isSvg; final bool isSvg;
final Map<String, String>? customHeaders; final Map<String, String>? customHeaders;
const FullScreenImageViewer({ const FullScreenImageViewer({
super.key, super.key,
required this.imageData, this.imageData,
this.imageBytes,
required this.tag, required this.tag,
this.isSvg = false, this.isSvg = false,
this.customHeaders, this.customHeaders,
}); }) : assert(imageData != null || imageBytes != null,
'Either imageData or imageBytes must be provided');
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
Widget imageWidget; Widget imageWidget;
if (imageData.startsWith('http')) { // If we have raw bytes, use them directly
if (imageData == null && imageBytes != null) {
if (isSvg || _isSvgBytes(imageBytes!)) {
imageWidget = SvgPicture.memory(
imageBytes!,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Center(
child: Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 48,
),
),
);
} else {
imageWidget = Image.memory(imageBytes!, fit: BoxFit.contain);
}
} else if (imageData != null && imageData!.startsWith('http')) {
// Get authentication headers if available // Get authentication headers if available
final defaultHeaders = buildImageHeadersFromWidgetRef(ref); final defaultHeaders = buildImageHeadersFromWidgetRef(ref);
final headers = _mergeHeaders(defaultHeaders, customHeaders); final headers = _mergeHeaders(defaultHeaders, customHeaders);
if (isSvg || _isSvgUrl(imageData)) { if (isSvg || _isSvgUrl(imageData!)) {
imageWidget = SvgPicture.network( imageWidget = SvgPicture.network(
imageData, imageData!,
fit: BoxFit.contain, fit: BoxFit.contain,
headers: headers, headers: headers,
placeholderBuilder: (context) => Center( placeholderBuilder: (context) => Center(
@@ -750,7 +819,7 @@ class FullScreenImageViewer extends ConsumerWidget {
} else { } else {
final cacheManager = ref.watch(selfSignedImageCacheManagerProvider); final cacheManager = ref.watch(selfSignedImageCacheManagerProvider);
imageWidget = CachedNetworkImage( imageWidget = CachedNetworkImage(
imageUrl: imageData, imageUrl: imageData!,
fit: BoxFit.contain, fit: BoxFit.contain,
cacheManager: cacheManager, cacheManager: cacheManager,
httpHeaders: headers, httpHeaders: headers,
@@ -768,24 +837,24 @@ class FullScreenImageViewer extends ConsumerWidget {
), ),
); );
} }
} else { } else if (imageData != null) {
try { try {
String actualBase64; String actualBase64;
if (imageData.startsWith('data:')) { if (imageData!.startsWith('data:')) {
final commaIndex = imageData.indexOf(','); final commaIndex = imageData!.indexOf(',');
if (commaIndex == -1) { if (commaIndex == -1) {
throw const FormatException('Invalid data URI'); throw const FormatException('Invalid data URI');
} }
actualBase64 = imageData.substring(commaIndex + 1); actualBase64 = imageData!.substring(commaIndex + 1);
} else { } else {
actualBase64 = imageData; actualBase64 = imageData!;
} }
final imageBytes = base64.decode(actualBase64); final decodedBytes = base64.decode(actualBase64);
// Check if SVG content // Check if SVG content
if (isSvg || _isSvgDataUrl(imageData) || _isSvgBytes(imageBytes)) { if (isSvg || _isSvgDataUrl(imageData!) || _isSvgBytes(decodedBytes)) {
imageWidget = SvgPicture.memory( imageWidget = SvgPicture.memory(
imageBytes, decodedBytes,
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Center( errorBuilder: (context, error, stackTrace) => Center(
child: Icon( child: Icon(
@@ -796,7 +865,7 @@ class FullScreenImageViewer extends ConsumerWidget {
), ),
); );
} else { } else {
imageWidget = Image.memory(imageBytes, fit: BoxFit.contain); imageWidget = Image.memory(decodedBytes, fit: BoxFit.contain);
} }
} catch (e) { } catch (e) {
imageWidget = Center( imageWidget = Center(
@@ -807,6 +876,15 @@ class FullScreenImageViewer extends ConsumerWidget {
), ),
); );
} }
} else {
// No image data available - show error
imageWidget = Center(
child: Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 48,
),
);
} }
final tokens = context.colorTokens; final tokens = context.colorTokens;
@@ -860,7 +938,11 @@ class FullScreenImageViewer extends ConsumerWidget {
Uint8List bytes; Uint8List bytes;
String? fileExtension; String? fileExtension;
if (imageData.startsWith('http')) { // If we have raw bytes, use them directly
if (imageData == null && imageBytes != null) {
bytes = imageBytes!;
fileExtension = isSvg ? 'svg' : 'png';
} else if (imageData!.startsWith('http')) {
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
final authToken = ref.read(authTokenProvider3); final authToken = ref.read(authTokenProvider3);
final headers = <String, String>{}; final headers = <String, String>{};
@@ -878,7 +960,7 @@ class FullScreenImageViewer extends ConsumerWidget {
final client = api?.dio ?? dio.Dio(); final client = api?.dio ?? dio.Dio();
final response = await client.get<List<int>>( final response = await client.get<List<int>>(
imageData, imageData!,
options: dio.Options( options: dio.Options(
responseType: dio.ResponseType.bytes, responseType: dio.ResponseType.bytes,
headers: mergedHeaders, headers: mergedHeaders,
@@ -895,7 +977,7 @@ class FullScreenImageViewer extends ConsumerWidget {
fileExtension = contentType.split('/').last; fileExtension = contentType.split('/').last;
if (fileExtension == 'jpeg') fileExtension = 'jpg'; if (fileExtension == 'jpeg') fileExtension = 'jpg';
} else { } else {
final uri = Uri.tryParse(imageData); final uri = Uri.tryParse(imageData!);
final lastSegment = uri?.pathSegments.isNotEmpty == true final lastSegment = uri?.pathSegments.isNotEmpty == true
? uri!.pathSegments.last ? uri!.pathSegments.last
: ''; : '';
@@ -907,20 +989,23 @@ class FullScreenImageViewer extends ConsumerWidget {
} }
} }
} }
} else { } else if (imageData != null) {
String actualBase64 = imageData; String actualBase64 = imageData!;
if (imageData.startsWith('data:')) { if (imageData!.startsWith('data:')) {
final commaIndex = imageData.indexOf(','); final commaIndex = imageData!.indexOf(',');
final meta = imageData.substring(5, commaIndex); // image/png;base64 final meta = imageData!.substring(5, commaIndex); // image/png;base64
final slashIdx = meta.indexOf('/'); final slashIdx = meta.indexOf('/');
final semicolonIdx = meta.indexOf(';'); final semicolonIdx = meta.indexOf(';');
if (slashIdx != -1 && semicolonIdx != -1 && slashIdx < semicolonIdx) { if (slashIdx != -1 && semicolonIdx != -1 && slashIdx < semicolonIdx) {
final subtype = meta.substring(slashIdx + 1, semicolonIdx); final subtype = meta.substring(slashIdx + 1, semicolonIdx);
fileExtension = subtype == 'jpeg' ? 'jpg' : subtype; fileExtension = subtype == 'jpeg' ? 'jpg' : subtype;
} }
actualBase64 = imageData.substring(commaIndex + 1); actualBase64 = imageData!.substring(commaIndex + 1);
} }
bytes = base64.decode(actualBase64); bytes = base64.decode(actualBase64);
} else {
// No image data available
return;
} }
fileExtension ??= 'png'; fileExtension ??= 'png';

View File

@@ -13,6 +13,7 @@ import '../providers/chat_providers.dart';
import '../../../shared/services/tasks/task_queue.dart'; import '../../../shared/services/tasks/task_queue.dart';
import '../../../shared/utils/conversation_context_menu.dart'; import '../../../shared/utils/conversation_context_menu.dart';
import '../../tools/providers/tools_providers.dart'; import '../../tools/providers/tools_providers.dart';
import '../utils/file_utils.dart';
// Pre-compiled regex for extracting file IDs from URLs (performance optimization) // Pre-compiled regex for extracting file IDs from URLs (performance optimization)
// Handles both /api/v1/files/{id} and /api/v1/files/{id}/content formats // Handles both /api/v1/files/{id} and /api/v1/files/{id}/content formats
@@ -84,16 +85,21 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
final allFiles = widget.message.files!; final allFiles = widget.message.files!;
// Separate images and non-image files // Separate images and non-image files
// Match OpenWebUI: type === 'image' OR content_type starts with 'image/'
final imageFiles = allFiles final imageFiles = allFiles
.where( .where(
(file) => (file) =>
file is Map && file['type'] == 'image' && file['url'] != null, file is Map &&
isImageFile(file) &&
getFileUrl(file) != null,
) )
.toList(); .toList();
final nonImageFiles = allFiles final nonImageFiles = allFiles
.where( .where(
(file) => (file) =>
file is Map && file['type'] != 'image' && file['url'] != null, file is Map &&
!isImageFile(file) &&
getFileUrl(file) != null,
) )
.toList(); .toList();
@@ -131,7 +137,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
Widget _buildFileImageLayout(List<dynamic> imageFiles, int imageCount) { Widget _buildFileImageLayout(List<dynamic> imageFiles, int imageCount) {
if (imageCount == 1) { if (imageCount == 1) {
final file = imageFiles[0]; final file = imageFiles[0];
final String imageUrl = file['url'] as String; final imageUrl = getFileUrl(file);
if (imageUrl == null) return const SizedBox.shrink();
return Row( return Row(
key: ValueKey('user_file_single_$imageUrl'), key: ValueKey('user_file_single_$imageUrl'),
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
@@ -175,7 +182,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
children: imageFiles.asMap().entries.map((entry) { children: imageFiles.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final file = entry.value; final file = entry.value;
final String imageUrl = file['url'] as String; final imageUrl = getFileUrl(file);
if (imageUrl == null) return const SizedBox.shrink();
return Padding( return Padding(
padding: EdgeInsets.only(left: index == 0 ? 0 : Spacing.xs), padding: EdgeInsets.only(left: index == 0 ? 0 : Spacing.xs),
child: Container( child: Container(
@@ -223,7 +231,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
spacing: Spacing.xs, spacing: Spacing.xs,
runSpacing: Spacing.xs, runSpacing: Spacing.xs,
children: imageFiles.map((file) { children: imageFiles.map((file) {
final String imageUrl = file['url'] as String; final imageUrl = getFileUrl(file);
if (imageUrl == null) return const SizedBox.shrink();
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.circular(AppBorderRadius.md),