Merge pull request #196 from cogwheel0/svg-image-attachment-support
svg-image-attachment-support
This commit is contained in:
@@ -272,6 +272,10 @@ 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'];
|
||||||
|
final headers = _coerceStringMap(entry['headers']);
|
||||||
|
if (headers != null && headers.isNotEmpty) {
|
||||||
|
fileMap['headers'] = headers;
|
||||||
|
}
|
||||||
allFiles.add(fileMap);
|
allFiles.add(fileMap);
|
||||||
|
|
||||||
final url = entry['url'].toString();
|
final url = entry['url'].toString();
|
||||||
@@ -388,6 +392,24 @@ List<Map<String, dynamic>> _parseStatusHistoryField(dynamic raw) {
|
|||||||
return const <Map<String, dynamic>>[];
|
return const <Map<String, dynamic>>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _coerceStringMap(dynamic raw) {
|
||||||
|
if (raw is Map) {
|
||||||
|
final result = <String, String>{};
|
||||||
|
raw.forEach((key, value) {
|
||||||
|
final keyString = key?.toString();
|
||||||
|
final valueString = value?.toString();
|
||||||
|
if (keyString != null &&
|
||||||
|
keyString.isNotEmpty &&
|
||||||
|
valueString != null &&
|
||||||
|
valueString.isNotEmpty) {
|
||||||
|
result[keyString] = valueString;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result.isEmpty ? null : result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
List<String> _coerceStringList(dynamic raw) {
|
List<String> _coerceStringList(dynamic raw) {
|
||||||
if (raw is List) {
|
if (raw is List) {
|
||||||
return raw
|
return raw
|
||||||
|
|||||||
@@ -981,8 +981,8 @@ Future<String?> _getFileAsBase64(dynamic api, String fileId) async {
|
|||||||
|
|
||||||
final ext = fileName.toLowerCase().split('.').last;
|
final ext = fileName.toLowerCase().split('.').last;
|
||||||
|
|
||||||
// Only process image files
|
// Only process image files (including SVG)
|
||||||
if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) {
|
if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].contains(ext)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,6 +1039,8 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
|||||||
mimeType = 'image/gif';
|
mimeType = 'image/gif';
|
||||||
} else if (ext == 'webp') {
|
} else if (ext == 'webp') {
|
||||||
mimeType = 'image/webp';
|
mimeType = 'image/webp';
|
||||||
|
} else if (ext == 'svg') {
|
||||||
|
mimeType = 'image/svg+xml';
|
||||||
}
|
}
|
||||||
|
|
||||||
final dataUrl = 'data:$mimeType;base64,$base64Data';
|
final dataUrl = 'data:$mimeType;base64,$base64Data';
|
||||||
|
|||||||
@@ -1063,6 +1063,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
),
|
),
|
||||||
disableAnimation:
|
disableAnimation:
|
||||||
false, // Keep animations enabled to prevent black display
|
false, // Keep animations enabled to prevent black display
|
||||||
|
httpHeaders: _headersForFile(imageFiles[0]),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1087,12 +1088,31 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
),
|
),
|
||||||
disableAnimation:
|
disableAnimation:
|
||||||
false, // Keep animations enabled to prevent black display
|
false, // Keep animations enabled to prevent black display
|
||||||
|
httpHeaders: _headersForFile(file),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _headersForFile(dynamic file) {
|
||||||
|
if (file is! Map) return null;
|
||||||
|
final rawHeaders = file['headers'];
|
||||||
|
if (rawHeaders is! Map) return null;
|
||||||
|
final result = <String, String>{};
|
||||||
|
rawHeaders.forEach((key, value) {
|
||||||
|
final keyString = key?.toString();
|
||||||
|
final valueString = value?.toString();
|
||||||
|
if (keyString != null &&
|
||||||
|
keyString.isNotEmpty &&
|
||||||
|
valueString != null &&
|
||||||
|
valueString.isNotEmpty) {
|
||||||
|
result[keyString] = valueString;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result.isEmpty ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildNonImageFiles(List<dynamic> nonImageFiles) {
|
Widget _buildNonImageFiles(List<dynamic> nonImageFiles) {
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: Spacing.sm,
|
spacing: Spacing.sm,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:dio/dio.dart' as dio;
|
import 'package:dio/dio.dart' as dio;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
@@ -22,6 +23,7 @@ final _globalImageCache = <String, String>{};
|
|||||||
final _globalLoadingStates = <String, bool>{};
|
final _globalLoadingStates = <String, bool>{};
|
||||||
final _globalErrorStates = <String, String>{};
|
final _globalErrorStates = <String, String>{};
|
||||||
final _globalImageBytesCache = <String, Uint8List>{};
|
final _globalImageBytesCache = <String, Uint8List>{};
|
||||||
|
final _globalSvgStates = <String, bool>{};
|
||||||
final _base64WhitespacePattern = RegExp(r'\s');
|
final _base64WhitespacePattern = RegExp(r'\s');
|
||||||
|
|
||||||
Uint8List _decodeImageData(String data) {
|
Uint8List _decodeImageData(String data) {
|
||||||
@@ -37,6 +39,55 @@ Uint8List _decodeImageData(String data) {
|
|||||||
return base64.decode(payload);
|
return base64.decode(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks if data URL or content indicates SVG format.
|
||||||
|
bool _isSvgDataUrl(String data) {
|
||||||
|
final lower = data.toLowerCase();
|
||||||
|
return lower.startsWith('data:image/svg+xml');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a URL points to an SVG file.
|
||||||
|
bool _isSvgUrl(String url) {
|
||||||
|
final lowerUrl = url.toLowerCase();
|
||||||
|
|
||||||
|
// Check for .svg file extension (with or without query string)
|
||||||
|
final queryIndex = lowerUrl.indexOf('?');
|
||||||
|
final pathPart = queryIndex >= 0
|
||||||
|
? lowerUrl.substring(0, queryIndex)
|
||||||
|
: lowerUrl;
|
||||||
|
if (pathPart.endsWith('.svg')) return true;
|
||||||
|
|
||||||
|
// Check for SVG MIME type in query parameters only (not in path)
|
||||||
|
// This handles cases like ?format=image/svg+xml or &type=image/svg+xml
|
||||||
|
if (queryIndex >= 0) {
|
||||||
|
final queryPart = lowerUrl.substring(queryIndex);
|
||||||
|
if (queryPart.contains('image/svg+xml')) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if decoded bytes represent SVG content by looking for the SVG tag.
|
||||||
|
bool _isSvgBytes(Uint8List bytes) {
|
||||||
|
// Check first 1KB for SVG tag (not just XML declaration, which is too broad)
|
||||||
|
final checkLength = bytes.length < 1024 ? bytes.length : 1024;
|
||||||
|
final header = utf8.decode(
|
||||||
|
bytes.sublist(0, checkLength),
|
||||||
|
allowMalformed: true,
|
||||||
|
);
|
||||||
|
return header.toLowerCase().contains('<svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _mergeHeaders(
|
||||||
|
Map<String, String>? defaults,
|
||||||
|
Map<String, String>? overrides,
|
||||||
|
) {
|
||||||
|
if ((defaults == null || defaults.isEmpty) &&
|
||||||
|
(overrides == null || overrides.isEmpty)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {...?defaults, ...?overrides};
|
||||||
|
}
|
||||||
|
|
||||||
class EnhancedImageAttachment extends ConsumerStatefulWidget {
|
class EnhancedImageAttachment extends ConsumerStatefulWidget {
|
||||||
final String attachmentId;
|
final String attachmentId;
|
||||||
final bool isMarkdownFormat;
|
final bool isMarkdownFormat;
|
||||||
@@ -44,6 +95,7 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget {
|
|||||||
final BoxConstraints? constraints;
|
final BoxConstraints? constraints;
|
||||||
final bool isUserMessage;
|
final bool isUserMessage;
|
||||||
final bool disableAnimation;
|
final bool disableAnimation;
|
||||||
|
final Map<String, String>? httpHeaders;
|
||||||
|
|
||||||
const EnhancedImageAttachment({
|
const EnhancedImageAttachment({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -53,6 +105,7 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget {
|
|||||||
this.constraints,
|
this.constraints,
|
||||||
this.isUserMessage = false,
|
this.isUserMessage = false,
|
||||||
this.disableAnimation = false,
|
this.disableAnimation = false,
|
||||||
|
this.httpHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -68,6 +121,7 @@ class _EnhancedImageAttachmentState
|
|||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
bool _isDecoding = false;
|
bool _isDecoding = false;
|
||||||
|
bool _isSvg = false;
|
||||||
late final String _heroTag;
|
late final String _heroTag;
|
||||||
// Removed unused animation and state flags
|
// Removed unused animation and state flags
|
||||||
|
|
||||||
@@ -107,10 +161,12 @@ class _EnhancedImageAttachmentState
|
|||||||
if (_globalImageCache.containsKey(widget.attachmentId)) {
|
if (_globalImageCache.containsKey(widget.attachmentId)) {
|
||||||
final cachedData = _globalImageCache[widget.attachmentId]!;
|
final cachedData = _globalImageCache[widget.attachmentId]!;
|
||||||
final cachedBytes = _globalImageBytesCache[widget.attachmentId];
|
final cachedBytes = _globalImageBytesCache[widget.attachmentId];
|
||||||
|
final cachedIsSvg = _globalSvgStates[widget.attachmentId] ?? false;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_cachedImageData = cachedData;
|
_cachedImageData = cachedData;
|
||||||
_cachedBytes = cachedBytes;
|
_cachedBytes = cachedBytes;
|
||||||
|
_isSvg = cachedIsSvg;
|
||||||
_isLoading = cachedBytes == null && !_isRemoteContent(cachedData);
|
_isLoading = cachedBytes == null && !_isRemoteContent(cachedData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -129,13 +185,18 @@ class _EnhancedImageAttachmentState
|
|||||||
final attachmentId = widget.attachmentId;
|
final attachmentId = widget.attachmentId;
|
||||||
|
|
||||||
if (attachmentId.startsWith('data:') || attachmentId.startsWith('http')) {
|
if (attachmentId.startsWith('data:') || attachmentId.startsWith('http')) {
|
||||||
|
// Detect SVG from data URL or HTTP URL
|
||||||
|
final isSvgContent =
|
||||||
|
_isSvgDataUrl(attachmentId) || _isSvgUrl(attachmentId);
|
||||||
_globalImageCache[attachmentId] = attachmentId;
|
_globalImageCache[attachmentId] = attachmentId;
|
||||||
_globalLoadingStates[attachmentId] = false;
|
_globalLoadingStates[attachmentId] = false;
|
||||||
|
_globalSvgStates[attachmentId] = isSvgContent;
|
||||||
final cachedBytes = _globalImageBytesCache[attachmentId];
|
final cachedBytes = _globalImageBytesCache[attachmentId];
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_cachedImageData = attachmentId;
|
_cachedImageData = attachmentId;
|
||||||
_cachedBytes = cachedBytes;
|
_cachedBytes = cachedBytes;
|
||||||
|
_isSvg = isSvgContent;
|
||||||
_isLoading = cachedBytes == null && !_isRemoteContent(attachmentId);
|
_isLoading = cachedBytes == null && !_isRemoteContent(attachmentId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -149,12 +210,15 @@ class _EnhancedImageAttachmentState
|
|||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
final fullUrl = api.baseUrl + attachmentId;
|
final fullUrl = api.baseUrl + attachmentId;
|
||||||
|
final isSvgContent = _isSvgUrl(fullUrl);
|
||||||
_globalImageCache[attachmentId] = fullUrl;
|
_globalImageCache[attachmentId] = fullUrl;
|
||||||
_globalLoadingStates[attachmentId] = false;
|
_globalLoadingStates[attachmentId] = false;
|
||||||
|
_globalSvgStates[attachmentId] = isSvgContent;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_cachedImageData = fullUrl;
|
_cachedImageData = fullUrl;
|
||||||
_cachedBytes = null;
|
_cachedBytes = null;
|
||||||
|
_isSvg = isSvgContent;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,10 +248,14 @@ class _EnhancedImageAttachmentState
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track if this is an SVG file based on extension
|
||||||
|
final isSvgFile = ext == 'svg';
|
||||||
|
|
||||||
final fileContent = await api.getFileContent(attachmentId);
|
final fileContent = await api.getFileContent(attachmentId);
|
||||||
|
|
||||||
_globalImageCache[attachmentId] = fileContent;
|
_globalImageCache[attachmentId] = fileContent;
|
||||||
_globalLoadingStates[attachmentId] = false;
|
_globalLoadingStates[attachmentId] = false;
|
||||||
|
_globalSvgStates[attachmentId] = isSvgFile;
|
||||||
|
|
||||||
if (_globalImageCache.length > 50) {
|
if (_globalImageCache.length > 50) {
|
||||||
final firstKey = _globalImageCache.keys.first;
|
final firstKey = _globalImageCache.keys.first;
|
||||||
@@ -195,12 +263,14 @@ class _EnhancedImageAttachmentState
|
|||||||
_globalLoadingStates.remove(firstKey);
|
_globalLoadingStates.remove(firstKey);
|
||||||
_globalErrorStates.remove(firstKey);
|
_globalErrorStates.remove(firstKey);
|
||||||
_globalImageBytesCache.remove(firstKey);
|
_globalImageBytesCache.remove(firstKey);
|
||||||
|
_globalSvgStates.remove(firstKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_cachedImageData = fileContent;
|
_cachedImageData = fileContent;
|
||||||
_cachedBytes = null;
|
_cachedBytes = null;
|
||||||
|
_isSvg = isSvgFile;
|
||||||
_isLoading = !_isRemoteContent(fileContent);
|
_isLoading = !_isRemoteContent(fileContent);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -234,9 +304,18 @@ class _EnhancedImageAttachmentState
|
|||||||
debugLabel: 'decode_image',
|
debugLabel: 'decode_image',
|
||||||
);
|
);
|
||||||
_globalImageBytesCache[widget.attachmentId] = bytes;
|
_globalImageBytesCache[widget.attachmentId] = bytes;
|
||||||
|
|
||||||
|
// Use byte content as authoritative SVG detection when positive, but
|
||||||
|
// preserve prior true-hints (e.g., from file extension) if detection fails.
|
||||||
|
final previousHint = _globalSvgStates[widget.attachmentId] ?? _isSvg;
|
||||||
|
final detectedSvg = _isSvgBytes(bytes) || _isSvgDataUrl(data);
|
||||||
|
final isSvgContent = detectedSvg ? true : previousHint;
|
||||||
|
_globalSvgStates[widget.attachmentId] = isSvgContent;
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_cachedBytes = bytes;
|
_cachedBytes = bytes;
|
||||||
|
_isSvg = isSvgContent;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} on FormatException {
|
} on FormatException {
|
||||||
@@ -255,6 +334,7 @@ class _EnhancedImageAttachmentState
|
|||||||
_globalLoadingStates[widget.attachmentId] = false;
|
_globalLoadingStates[widget.attachmentId] = false;
|
||||||
_globalImageCache.remove(widget.attachmentId);
|
_globalImageCache.remove(widget.attachmentId);
|
||||||
_globalImageBytesCache.remove(widget.attachmentId);
|
_globalImageBytesCache.remove(widget.attachmentId);
|
||||||
|
_globalSvgStates.remove(widget.attachmentId);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -297,11 +377,14 @@ class _EnhancedImageAttachmentState
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle different image data formats
|
// Handle different image data formats
|
||||||
|
// Include fallback URL/data detection to match FullScreenImageViewer behavior
|
||||||
Widget imageWidget;
|
Widget imageWidget;
|
||||||
if (_cachedImageData!.startsWith('http')) {
|
if (_cachedImageData!.startsWith('http')) {
|
||||||
imageWidget = _buildNetworkImage();
|
final isSvgContent = _isSvg || _isSvgUrl(_cachedImageData!);
|
||||||
|
imageWidget = isSvgContent ? _buildNetworkSvg() : _buildNetworkImage();
|
||||||
} else {
|
} else {
|
||||||
imageWidget = _buildBase64Image();
|
final isSvgContent = _isSvg || _isSvgDataUrl(_cachedImageData!);
|
||||||
|
imageWidget = isSvgContent ? _buildBase64Svg() : _buildBase64Image();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show the image without fade transitions during streaming to prevent black display
|
// Always show the image without fade transitions during streaming to prevent black display
|
||||||
@@ -415,7 +498,8 @@ class _EnhancedImageAttachmentState
|
|||||||
|
|
||||||
Widget _buildNetworkImage() {
|
Widget _buildNetworkImage() {
|
||||||
// Get authentication headers if available
|
// Get authentication headers if available
|
||||||
final headers = buildImageHeadersFromWidgetRef(ref);
|
final defaultHeaders = buildImageHeadersFromWidgetRef(ref);
|
||||||
|
final headers = _mergeHeaders(defaultHeaders, widget.httpHeaders);
|
||||||
|
|
||||||
final cacheManager = ref.watch(selfSignedImageCacheManagerProvider);
|
final cacheManager = ref.watch(selfSignedImageCacheManagerProvider);
|
||||||
final imageWidget = CachedNetworkImage(
|
final imageWidget = CachedNetworkImage(
|
||||||
@@ -446,6 +530,40 @@ class _EnhancedImageAttachmentState
|
|||||||
return _wrapImage(imageWidget);
|
return _wrapImage(imageWidget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildNetworkSvg() {
|
||||||
|
// Get authentication headers if available
|
||||||
|
final defaultHeaders = buildImageHeadersFromWidgetRef(ref);
|
||||||
|
final headers = _mergeHeaders(defaultHeaders, widget.httpHeaders);
|
||||||
|
|
||||||
|
final svgWidget = SvgPicture.network(
|
||||||
|
_cachedImageData!,
|
||||||
|
key: ValueKey('svg_${widget.attachmentId}'),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
headers: headers,
|
||||||
|
placeholderBuilder: (context) => Container(
|
||||||
|
constraints: widget.constraints,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.shimmerBase,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: context.conduitTheme.buttonPrimary,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
_errorMessage = AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.failedToLoadImage(error.toString());
|
||||||
|
return _buildErrorState();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return _wrapImage(svgWidget);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildBase64Image() {
|
Widget _buildBase64Image() {
|
||||||
final bytes = _cachedBytes;
|
final bytes = _cachedBytes;
|
||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
@@ -466,6 +584,25 @@ class _EnhancedImageAttachmentState
|
|||||||
return _wrapImage(imageWidget);
|
return _wrapImage(imageWidget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildBase64Svg() {
|
||||||
|
final bytes = _cachedBytes;
|
||||||
|
if (bytes == null) {
|
||||||
|
return _buildLoadingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final svgWidget = SvgPicture.memory(
|
||||||
|
bytes,
|
||||||
|
key: ValueKey('svg_${widget.attachmentId}'),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
_errorMessage = AppLocalizations.of(context)!.failedToDecodeImage;
|
||||||
|
return _buildErrorState();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return _wrapImage(svgWidget);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _wrapImage(Widget imageWidget) {
|
Widget _wrapImage(Widget imageWidget) {
|
||||||
final wrappedImage = Container(
|
final wrappedImage = Container(
|
||||||
constraints:
|
constraints:
|
||||||
@@ -523,8 +660,12 @@ class _EnhancedImageAttachmentState
|
|||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
builder: (context) =>
|
builder: (context) => FullScreenImageViewer(
|
||||||
FullScreenImageViewer(imageData: _cachedImageData!, tag: _heroTag),
|
imageData: _cachedImageData!,
|
||||||
|
tag: _heroTag,
|
||||||
|
isSvg: _isSvg,
|
||||||
|
customHeaders: widget.httpHeaders,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -533,11 +674,15 @@ class _EnhancedImageAttachmentState
|
|||||||
class FullScreenImageViewer extends ConsumerWidget {
|
class FullScreenImageViewer extends ConsumerWidget {
|
||||||
final String imageData;
|
final String imageData;
|
||||||
final String tag;
|
final String tag;
|
||||||
|
final bool isSvg;
|
||||||
|
final Map<String, String>? customHeaders;
|
||||||
|
|
||||||
const FullScreenImageViewer({
|
const FullScreenImageViewer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imageData,
|
required this.imageData,
|
||||||
required this.tag,
|
required this.tag,
|
||||||
|
this.isSvg = false,
|
||||||
|
this.customHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -546,8 +691,28 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
|
|
||||||
if (imageData.startsWith('http')) {
|
if (imageData.startsWith('http')) {
|
||||||
// Get authentication headers if available
|
// Get authentication headers if available
|
||||||
final headers = buildImageHeadersFromWidgetRef(ref);
|
final defaultHeaders = buildImageHeadersFromWidgetRef(ref);
|
||||||
|
final headers = _mergeHeaders(defaultHeaders, customHeaders);
|
||||||
|
|
||||||
|
if (isSvg || _isSvgUrl(imageData)) {
|
||||||
|
imageWidget = SvgPicture.network(
|
||||||
|
imageData,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
headers: headers,
|
||||||
|
placeholderBuilder: (context) => Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: context.conduitTheme.buttonPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorBuilder: (context, error, stackTrace) => Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: context.conduitTheme.error,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
final cacheManager = ref.watch(selfSignedImageCacheManagerProvider);
|
final cacheManager = ref.watch(selfSignedImageCacheManagerProvider);
|
||||||
imageWidget = CachedNetworkImage(
|
imageWidget = CachedNetworkImage(
|
||||||
imageUrl: imageData,
|
imageUrl: imageData,
|
||||||
@@ -567,17 +732,37 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
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) {
|
||||||
|
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 imageBytes = base64.decode(actualBase64);
|
||||||
|
|
||||||
|
// Check if SVG content
|
||||||
|
if (isSvg || _isSvgDataUrl(imageData) || _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);
|
imageWidget = Image.memory(imageBytes, fit: BoxFit.contain);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
imageWidget = Center(
|
imageWidget = Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -654,13 +839,14 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
if (api != null && api.serverConfig.customHeaders.isNotEmpty) {
|
if (api != null && api.serverConfig.customHeaders.isNotEmpty) {
|
||||||
headers.addAll(api.serverConfig.customHeaders);
|
headers.addAll(api.serverConfig.customHeaders);
|
||||||
}
|
}
|
||||||
|
final mergedHeaders = _mergeHeaders(headers, customHeaders);
|
||||||
|
|
||||||
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: headers.isNotEmpty ? headers : null,
|
headers: mergedHeaders,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final data = response.data;
|
final data = response.data;
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
|||||||
maxHeight: 350,
|
maxHeight: 350,
|
||||||
),
|
),
|
||||||
disableAnimation: widget.isStreaming,
|
disableAnimation: widget.isStreaming,
|
||||||
|
httpHeaders: _headersForFile(imageFiles[0]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -191,6 +192,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
|||||||
maxHeight: 180,
|
maxHeight: 180,
|
||||||
),
|
),
|
||||||
disableAnimation: widget.isStreaming,
|
disableAnimation: widget.isStreaming,
|
||||||
|
httpHeaders: _headersForFile(entry.value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -232,6 +234,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
|||||||
maxHeight: imageCount == 3 ? 135 : 90,
|
maxHeight: imageCount == 3 ? 135 : 90,
|
||||||
),
|
),
|
||||||
disableAnimation: widget.isStreaming,
|
disableAnimation: widget.isStreaming,
|
||||||
|
httpHeaders: _headersForFile(file),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -401,6 +404,24 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _headersForFile(dynamic file) {
|
||||||
|
if (file is! Map) return null;
|
||||||
|
final rawHeaders = file['headers'];
|
||||||
|
if (rawHeaders is! Map) return null;
|
||||||
|
final result = <String, String>{};
|
||||||
|
rawHeaders.forEach((key, value) {
|
||||||
|
final keyString = key?.toString();
|
||||||
|
final valueString = value?.toString();
|
||||||
|
if (keyString != null &&
|
||||||
|
keyString.isNotEmpty &&
|
||||||
|
valueString != null &&
|
||||||
|
valueString.isNotEmpty) {
|
||||||
|
result[keyString] = valueString;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result.isEmpty ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
// Assistant-only helpers removed; this widget renders only user bubbles.
|
// Assistant-only helpers removed; this widget renders only user bubbles.
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -652,7 +652,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.3"
|
version: "0.1.3"
|
||||||
flutter_svg:
|
flutter_svg:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_svg
|
name: flutter_svg
|
||||||
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
|
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ dependencies:
|
|||||||
flutter_callkit_incoming: ^3.0.0
|
flutter_callkit_incoming: ^3.0.0
|
||||||
flutter_app_intents: ^0.7.0
|
flutter_app_intents: ^0.7.0
|
||||||
quick_actions: 1.1.0
|
quick_actions: 1.1.0
|
||||||
|
flutter_svg: ^2.2.3
|
||||||
|
|
||||||
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user