refactor: Enhance image attachment handling in chat
- Introduced asynchronous decoding for base64 image data to improve performance and responsiveness. - Added caching for decoded image bytes to optimize loading times and reduce redundant processing. - Updated error handling to provide clearer feedback when image loading fails, enhancing user experience. - Refactored loading logic to streamline the process of checking cached images and managing loading states. - Improved the handling of data URLs and relative URLs for better image attachment management.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
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';
|
||||||
@@ -18,6 +18,28 @@ import '../../../core/utils/debug_logger.dart';
|
|||||||
final _globalImageCache = <String, String>{};
|
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 _base64WhitespacePattern = RegExp(r'\s');
|
||||||
|
|
||||||
|
Future<Uint8List> _decodeImageDataAsync(String data) async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return _decodeImageData(data);
|
||||||
|
}
|
||||||
|
return compute(_decodeImageData, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _decodeImageData(String data) {
|
||||||
|
var payload = data;
|
||||||
|
if (payload.startsWith('data:')) {
|
||||||
|
final commaIndex = payload.indexOf(',');
|
||||||
|
if (commaIndex == -1) {
|
||||||
|
throw FormatException('Invalid data URI');
|
||||||
|
}
|
||||||
|
payload = payload.substring(commaIndex + 1);
|
||||||
|
}
|
||||||
|
payload = payload.replaceAll(_base64WhitespacePattern, '');
|
||||||
|
return base64.decode(payload);
|
||||||
|
}
|
||||||
|
|
||||||
class EnhancedImageAttachment extends ConsumerStatefulWidget {
|
class EnhancedImageAttachment extends ConsumerStatefulWidget {
|
||||||
final String attachmentId;
|
final String attachmentId;
|
||||||
@@ -46,8 +68,11 @@ class _EnhancedImageAttachmentState
|
|||||||
extends ConsumerState<EnhancedImageAttachment>
|
extends ConsumerState<EnhancedImageAttachment>
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin {
|
||||||
String? _cachedImageData;
|
String? _cachedImageData;
|
||||||
|
Uint8List? _cachedBytes;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
bool _isDecoding = false;
|
||||||
|
late final String _heroTag;
|
||||||
// Removed unused animation and state flags
|
// Removed unused animation and state flags
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,6 +81,7 @@ class _EnhancedImageAttachmentState
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_heroTag = 'image_${widget.attachmentId}_${identityHashCode(this)}';
|
||||||
// Defer loading until after first frame to avoid accessing inherited widgets
|
// Defer loading until after first frame to avoid accessing inherited widgets
|
||||||
// (e.g., Localizations) during initState
|
// (e.g., Localizations) during initState
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -71,71 +97,75 @@ class _EnhancedImageAttachmentState
|
|||||||
|
|
||||||
Future<void> _loadImage() async {
|
Future<void> _loadImage() async {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
// Check global cache first
|
final cachedError = _globalErrorStates[widget.attachmentId];
|
||||||
|
if (cachedError != null) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = cachedError;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_globalImageCache.containsKey(widget.attachmentId)) {
|
if (_globalImageCache.containsKey(widget.attachmentId)) {
|
||||||
|
final cachedData = _globalImageCache[widget.attachmentId]!;
|
||||||
|
final cachedBytes = _globalImageBytesCache[widget.attachmentId];
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_cachedImageData = _globalImageCache[widget.attachmentId];
|
_cachedImageData = cachedData;
|
||||||
|
_cachedBytes = cachedBytes;
|
||||||
|
_isLoading = cachedBytes == null && !_isRemoteContent(cachedData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cachedBytes == null && !_isRemoteContent(cachedData)) {
|
||||||
|
await _decodeAndAssign(cachedData, l10n);
|
||||||
|
} else if (mounted) {
|
||||||
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there was a previous error
|
|
||||||
if (_globalErrorStates.containsKey(widget.attachmentId)) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_errorMessage = _globalErrorStates[widget.attachmentId];
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set loading state
|
|
||||||
_globalLoadingStates[widget.attachmentId] = true;
|
_globalLoadingStates[widget.attachmentId] = true;
|
||||||
|
|
||||||
// Check if this is already a data URL or base64 image
|
final attachmentId = widget.attachmentId;
|
||||||
if (widget.attachmentId.startsWith('data:') ||
|
|
||||||
widget.attachmentId.startsWith('http')) {
|
if (attachmentId.startsWith('data:') || attachmentId.startsWith('http')) {
|
||||||
_globalImageCache[widget.attachmentId] = widget.attachmentId;
|
_globalImageCache[attachmentId] = attachmentId;
|
||||||
_globalLoadingStates[widget.attachmentId] = false;
|
_globalLoadingStates[attachmentId] = false;
|
||||||
|
final cachedBytes = _globalImageBytesCache[attachmentId];
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_cachedImageData = widget.attachmentId;
|
_cachedImageData = attachmentId;
|
||||||
_isLoading = false;
|
_cachedBytes = cachedBytes;
|
||||||
|
_isLoading = cachedBytes == null && !_isRemoteContent(attachmentId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!_isRemoteContent(attachmentId) && cachedBytes == null) {
|
||||||
|
await _decodeAndAssign(attachmentId, l10n);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a relative URL that needs base URL prepending
|
if (attachmentId.startsWith('/')) {
|
||||||
if (widget.attachmentId.startsWith('/')) {
|
|
||||||
// This is a relative URL, prepend the base URL
|
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
final fullUrl = api.baseUrl + widget.attachmentId;
|
final fullUrl = api.baseUrl + attachmentId;
|
||||||
_globalImageCache[widget.attachmentId] = fullUrl;
|
_globalImageCache[attachmentId] = fullUrl;
|
||||||
_globalLoadingStates[widget.attachmentId] = false;
|
_globalLoadingStates[attachmentId] = false;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_cachedImageData = fullUrl;
|
_cachedImageData = fullUrl;
|
||||||
|
_cachedBytes = null;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// If API service is not available, show error
|
|
||||||
final error = l10n.unableToLoadImage;
|
final error = l10n.unableToLoadImage;
|
||||||
_globalErrorStates[widget.attachmentId] = error;
|
_cacheError(error);
|
||||||
_globalLoadingStates[widget.attachmentId] = false;
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_errorMessage = error;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,70 +173,97 @@ class _EnhancedImageAttachmentState
|
|||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
if (api == null) {
|
if (api == null) {
|
||||||
final error = l10n.apiUnavailable;
|
final error = l10n.apiUnavailable;
|
||||||
_globalErrorStates[widget.attachmentId] = error;
|
_cacheError(error);
|
||||||
_globalLoadingStates[widget.attachmentId] = false;
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_errorMessage = error;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get file info to check if it's an image
|
final fileInfo = await api.getFileInfo(attachmentId);
|
||||||
final fileInfo = await api.getFileInfo(widget.attachmentId);
|
|
||||||
final fileName = _extractFileName(fileInfo);
|
final fileName = _extractFileName(fileInfo);
|
||||||
final ext = fileName.toLowerCase().split('.').last;
|
final ext = fileName.toLowerCase().split('.').last;
|
||||||
|
|
||||||
if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].contains(ext)) {
|
if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].contains(ext)) {
|
||||||
final error = l10n.notAnImageFile(fileName);
|
final error = l10n.notAnImageFile(fileName);
|
||||||
_globalErrorStates[widget.attachmentId] = error;
|
_cacheError(error);
|
||||||
_globalLoadingStates[widget.attachmentId] = false;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fileContent = await api.getFileContent(attachmentId);
|
||||||
|
|
||||||
|
_globalImageCache[attachmentId] = fileContent;
|
||||||
|
_globalLoadingStates[attachmentId] = false;
|
||||||
|
|
||||||
|
if (_globalImageCache.length > 50) {
|
||||||
|
final firstKey = _globalImageCache.keys.first;
|
||||||
|
_globalImageCache.remove(firstKey);
|
||||||
|
_globalLoadingStates.remove(firstKey);
|
||||||
|
_globalErrorStates.remove(firstKey);
|
||||||
|
_globalImageBytesCache.remove(firstKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_cachedImageData = fileContent;
|
||||||
|
_cachedBytes = null;
|
||||||
|
_isLoading = !_isRemoteContent(fileContent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isRemoteContent(fileContent)) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = error;
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the image content
|
await _decodeAndAssign(fileContent, l10n);
|
||||||
final fileContent = await api.getFileContent(widget.attachmentId);
|
|
||||||
|
|
||||||
// Cache globally
|
|
||||||
_globalImageCache[widget.attachmentId] = fileContent;
|
|
||||||
_globalLoadingStates[widget.attachmentId] = false;
|
|
||||||
|
|
||||||
// Limit cache size
|
|
||||||
if (_globalImageCache.length > 50) {
|
|
||||||
final firstKey = _globalImageCache.keys.first;
|
|
||||||
_globalImageCache.remove(firstKey);
|
|
||||||
_globalLoadingStates.remove(firstKey);
|
|
||||||
_globalErrorStates.remove(firstKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_cachedImageData = fileContent;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final error = l10n.failedToLoadImage(e.toString());
|
final error = l10n.failedToLoadImage(e.toString());
|
||||||
_globalErrorStates[widget.attachmentId] = error;
|
_cacheError(error);
|
||||||
_globalLoadingStates[widget.attachmentId] = false;
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_errorMessage = error;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isRemoteContent(String data) => data.startsWith('http');
|
||||||
|
|
||||||
|
Future<void> _decodeAndAssign(String data, AppLocalizations l10n) async {
|
||||||
|
if (_isDecoding) return;
|
||||||
|
_isDecoding = true;
|
||||||
|
try {
|
||||||
|
final bytes = await _decodeImageDataAsync(data);
|
||||||
|
_globalImageBytesCache[widget.attachmentId] = bytes;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_cachedBytes = bytes;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} on FormatException {
|
||||||
|
final error = l10n.invalidImageFormat;
|
||||||
|
_cacheError(error);
|
||||||
|
} catch (_) {
|
||||||
|
final error = l10n.failedToDecodeImage;
|
||||||
|
_cacheError(error);
|
||||||
|
} finally {
|
||||||
|
_isDecoding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cacheError(String error) {
|
||||||
|
_globalErrorStates[widget.attachmentId] = error;
|
||||||
|
_globalLoadingStates[widget.attachmentId] = false;
|
||||||
|
_globalImageCache.remove(widget.attachmentId);
|
||||||
|
_globalImageBytesCache.remove(widget.attachmentId);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = error;
|
||||||
|
_cachedBytes = null;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
String _extractFileName(Map<String, dynamic> fileInfo) {
|
String _extractFileName(Map<String, dynamic> fileInfo) {
|
||||||
return fileInfo['filename'] ??
|
return fileInfo['filename'] ??
|
||||||
fileInfo['meta']?['name'] ??
|
fileInfo['meta']?['name'] ??
|
||||||
@@ -380,8 +437,12 @@ class _EnhancedImageAttachmentState
|
|||||||
imageUrl: _cachedImageData!,
|
imageUrl: _cachedImageData!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
httpHeaders: headers.isNotEmpty ? headers : null,
|
httpHeaders: headers.isNotEmpty ? headers : null,
|
||||||
fadeInDuration: const Duration(milliseconds: 200),
|
fadeInDuration: widget.disableAnimation
|
||||||
fadeOutDuration: const Duration(milliseconds: 200),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 200),
|
||||||
|
fadeOutDuration: widget.disableAnimation
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 200),
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
constraints: widget.constraints,
|
constraints: widget.constraints,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -399,37 +460,23 @@ class _EnhancedImageAttachmentState
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBase64Image() {
|
Widget _buildBase64Image() {
|
||||||
try {
|
final bytes = _cachedBytes;
|
||||||
// Extract base64 data from data URL if needed
|
if (bytes == null) {
|
||||||
String actualBase64;
|
return _buildLoadingState();
|
||||||
if (_cachedImageData!.startsWith('data:')) {
|
|
||||||
final commaIndex = _cachedImageData!.indexOf(',');
|
|
||||||
if (commaIndex != -1) {
|
|
||||||
actualBase64 = _cachedImageData!.substring(commaIndex + 1);
|
|
||||||
} else {
|
|
||||||
throw Exception(AppLocalizations.of(context)!.invalidDataUrl);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
actualBase64 = _cachedImageData!;
|
|
||||||
}
|
|
||||||
|
|
||||||
final imageBytes = base64.decode(actualBase64);
|
|
||||||
final imageWidget = Image.memory(
|
|
||||||
key: ValueKey('image_${widget.attachmentId}'),
|
|
||||||
imageBytes,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
gaplessPlayback: true, // Prevents flashing during rebuilds
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
_errorMessage = AppLocalizations.of(context)!.failedToDecodeImage;
|
|
||||||
return _buildErrorState();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return _wrapImage(imageWidget);
|
|
||||||
} catch (e) {
|
|
||||||
_errorMessage = AppLocalizations.of(context)!.invalidImageFormat;
|
|
||||||
return _buildErrorState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final imageWidget = Image.memory(
|
||||||
|
key: ValueKey('image_${widget.attachmentId}'),
|
||||||
|
bytes,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true, // Prevents flashing during rebuilds
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
_errorMessage = AppLocalizations.of(context)!.failedToDecodeImage;
|
||||||
|
return _buildErrorState();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return _wrapImage(imageWidget);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _wrapImage(Widget imageWidget) {
|
Widget _wrapImage(Widget imageWidget) {
|
||||||
@@ -458,8 +505,7 @@ class _EnhancedImageAttachmentState
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: widget.onTap ?? () => _showFullScreenImage(context),
|
onTap: widget.onTap ?? () => _showFullScreenImage(context),
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag:
|
tag: _heroTag,
|
||||||
'image_${widget.attachmentId}_${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
flightShuttleBuilder:
|
flightShuttleBuilder:
|
||||||
(
|
(
|
||||||
flightContext,
|
flightContext,
|
||||||
@@ -490,10 +536,8 @@ class _EnhancedImageAttachmentState
|
|||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
builder: (context) => FullScreenImageViewer(
|
builder: (context) =>
|
||||||
imageData: _cachedImageData!,
|
FullScreenImageViewer(imageData: _cachedImageData!, tag: _heroTag),
|
||||||
tag: 'image_${widget.attachmentId}',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user