Files
iiEsaywebUIapp/lib/features/chat/widgets/enhanced_image_attachment.dart

633 lines
19 KiB
Dart
Raw Normal View History

2025-08-20 18:39:30 +05:30
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
2025-08-21 12:49:41 +05:30
import 'package:flutter_animate/flutter_animate.dart';
2025-08-20 18:39:30 +05:30
import '../../../shared/theme/theme_extensions.dart';
import '../../../core/providers/app_providers.dart';
2025-08-20 23:42:31 +05:30
import '../../auth/providers/unified_auth_providers.dart';
2025-08-20 18:39:30 +05:30
2025-08-21 12:49:41 +05:30
// Simple global cache to prevent reloading
2025-08-20 18:39:30 +05:30
final _globalImageCache = <String, String>{};
2025-08-21 12:49:41 +05:30
final _globalLoadingStates = <String, bool>{};
final _globalErrorStates = <String, String>{};
2025-08-20 18:39:30 +05:30
class EnhancedImageAttachment extends ConsumerStatefulWidget {
final String attachmentId;
final bool isMarkdownFormat;
final VoidCallback? onTap;
final BoxConstraints? constraints;
final bool isUserMessage;
2025-08-21 12:49:41 +05:30
final bool disableAnimation;
2025-08-20 18:39:30 +05:30
const EnhancedImageAttachment({
super.key,
required this.attachmentId,
this.isMarkdownFormat = false,
this.onTap,
this.constraints,
this.isUserMessage = false,
2025-08-21 12:49:41 +05:30
this.disableAnimation = false,
2025-08-20 18:39:30 +05:30
});
@override
ConsumerState<EnhancedImageAttachment> createState() =>
_EnhancedImageAttachmentState();
}
class _EnhancedImageAttachmentState
extends ConsumerState<EnhancedImageAttachment>
2025-08-21 12:49:41 +05:30
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
2025-08-20 18:39:30 +05:30
String? _cachedImageData;
bool _isLoading = true;
String? _errorMessage;
2025-08-21 12:49:41 +05:30
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
bool _hasShownContent = false;
2025-08-20 18:39:30 +05:30
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
2025-08-21 12:49:41 +05:30
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
2025-08-20 18:39:30 +05:30
_loadImage();
}
2025-08-21 12:49:41 +05:30
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
2025-08-20 18:39:30 +05:30
Future<void> _loadImage() async {
// Check global cache first
if (_globalImageCache.containsKey(widget.attachmentId)) {
if (mounted) {
setState(() {
_cachedImageData = _globalImageCache[widget.attachmentId];
_isLoading = false;
2025-08-21 12:49:41 +05:30
_hasShownContent = true;
2025-08-20 18:39:30 +05:30
});
2025-08-21 12:49:41 +05:30
if (!widget.disableAnimation) {
_animationController.forward();
}
2025-08-20 18:39:30 +05:30
}
return;
}
2025-08-21 12:49:41 +05:30
// 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;
2025-08-20 18:39:30 +05:30
// Check if this is already a data URL or base64 image
if (widget.attachmentId.startsWith('data:') ||
widget.attachmentId.startsWith('http')) {
_globalImageCache[widget.attachmentId] = widget.attachmentId;
2025-08-21 12:49:41 +05:30
_globalLoadingStates[widget.attachmentId] = false;
2025-08-20 18:39:30 +05:30
if (mounted) {
setState(() {
_cachedImageData = widget.attachmentId;
_isLoading = false;
2025-08-21 12:49:41 +05:30
_hasShownContent = true;
2025-08-20 18:39:30 +05:30
});
2025-08-21 12:49:41 +05:30
if (!widget.disableAnimation) {
_animationController.forward();
}
2025-08-20 18:39:30 +05:30
}
return;
}
2025-08-20 23:42:31 +05:30
// Check if this is a relative URL that needs base URL prepending
if (widget.attachmentId.startsWith('/')) {
// This is a relative URL, prepend the base URL
final api = ref.read(apiServiceProvider);
if (api != null) {
final fullUrl = api.baseUrl + widget.attachmentId;
_globalImageCache[widget.attachmentId] = fullUrl;
2025-08-21 12:49:41 +05:30
_globalLoadingStates[widget.attachmentId] = false;
2025-08-20 23:42:31 +05:30
if (mounted) {
setState(() {
_cachedImageData = fullUrl;
_isLoading = false;
2025-08-21 12:49:41 +05:30
_hasShownContent = true;
2025-08-20 23:42:31 +05:30
});
2025-08-21 12:49:41 +05:30
if (!widget.disableAnimation) {
_animationController.forward();
}
2025-08-20 23:42:31 +05:30
}
return;
} else {
// If API service is not available, show error
2025-08-21 12:49:41 +05:30
final error = 'Unable to load image: API service not available';
_globalErrorStates[widget.attachmentId] = error;
_globalLoadingStates[widget.attachmentId] = false;
2025-08-20 23:42:31 +05:30
if (mounted) {
setState(() {
2025-08-21 12:49:41 +05:30
_errorMessage = error;
2025-08-20 23:42:31 +05:30
_isLoading = false;
});
}
return;
}
}
2025-08-20 18:39:30 +05:30
final api = ref.read(apiServiceProvider);
if (api == null) {
2025-08-21 12:49:41 +05:30
final error = 'API service not available';
_globalErrorStates[widget.attachmentId] = error;
_globalLoadingStates[widget.attachmentId] = false;
2025-08-20 18:39:30 +05:30
if (mounted) {
setState(() {
2025-08-21 12:49:41 +05:30
_errorMessage = error;
2025-08-20 18:39:30 +05:30
_isLoading = false;
});
}
return;
}
try {
// Get file info to check if it's an image
final fileInfo = await api.getFileInfo(widget.attachmentId);
final fileName = _extractFileName(fileInfo);
final ext = fileName.toLowerCase().split('.').last;
if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].contains(ext)) {
2025-08-21 12:49:41 +05:30
final error = 'Not an image file: $fileName';
_globalErrorStates[widget.attachmentId] = error;
_globalLoadingStates[widget.attachmentId] = false;
2025-08-20 18:39:30 +05:30
if (mounted) {
setState(() {
2025-08-21 12:49:41 +05:30
_errorMessage = error;
2025-08-20 18:39:30 +05:30
_isLoading = false;
});
}
return;
}
// Get the image content
final fileContent = await api.getFileContent(widget.attachmentId);
// Cache globally
_globalImageCache[widget.attachmentId] = fileContent;
2025-08-21 12:49:41 +05:30
_globalLoadingStates[widget.attachmentId] = false;
2025-08-20 18:39:30 +05:30
// Limit cache size
if (_globalImageCache.length > 50) {
2025-08-21 12:49:41 +05:30
final firstKey = _globalImageCache.keys.first;
_globalImageCache.remove(firstKey);
_globalLoadingStates.remove(firstKey);
_globalErrorStates.remove(firstKey);
2025-08-20 18:39:30 +05:30
}
if (mounted) {
setState(() {
_cachedImageData = fileContent;
_isLoading = false;
2025-08-21 12:49:41 +05:30
_hasShownContent = true;
2025-08-20 18:39:30 +05:30
});
2025-08-21 12:49:41 +05:30
if (!widget.disableAnimation) {
_animationController.forward();
}
2025-08-20 18:39:30 +05:30
}
} catch (e) {
2025-08-21 12:49:41 +05:30
final error = 'Failed to load image: ${e.toString()}';
_globalErrorStates[widget.attachmentId] = error;
_globalLoadingStates[widget.attachmentId] = false;
2025-08-20 18:39:30 +05:30
if (mounted) {
setState(() {
2025-08-21 12:49:41 +05:30
_errorMessage = error;
2025-08-20 18:39:30 +05:30
_isLoading = false;
});
}
}
}
String _extractFileName(Map<String, dynamic> fileInfo) {
return fileInfo['filename'] ??
fileInfo['meta']?['name'] ??
fileInfo['name'] ??
fileInfo['file_name'] ??
fileInfo['original_name'] ??
fileInfo['original_filename'] ??
'unknown';
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
2025-08-21 12:49:41 +05:30
// Use a single container with AnimatedSwitcher for smooth transitions
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.center,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: _buildContent(),
);
}
Widget _buildContent() {
2025-08-20 18:39:30 +05:30
if (_isLoading) {
return _buildLoadingState();
}
if (_errorMessage != null) {
return _buildErrorState();
}
if (_cachedImageData == null) {
return const SizedBox.shrink();
}
// Handle different image data formats
2025-08-21 12:49:41 +05:30
Widget imageWidget;
2025-08-20 18:39:30 +05:30
if (_cachedImageData!.startsWith('http')) {
2025-08-21 12:49:41 +05:30
imageWidget = _buildNetworkImage();
2025-08-20 18:39:30 +05:30
} else {
2025-08-21 12:49:41 +05:30
imageWidget = _buildBase64Image();
}
// Apply fade animation only when first showing content
if (!widget.disableAnimation && _hasShownContent) {
return FadeTransition(
opacity: _fadeAnimation,
child: imageWidget,
);
2025-08-20 18:39:30 +05:30
}
2025-08-21 12:49:41 +05:30
return imageWidget;
2025-08-20 18:39:30 +05:30
}
Widget _buildLoadingState() {
2025-08-21 12:49:41 +05:30
final constraints = widget.constraints ??
const BoxConstraints(
maxWidth: 300,
maxHeight: 300,
minHeight: 150,
minWidth: 200,
);
2025-08-20 18:39:30 +05:30
return Container(
2025-08-21 12:49:41 +05:30
key: const ValueKey('loading'),
constraints: constraints,
2025-08-20 18:39:30 +05:30
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: context.conduitTheme.dividerColor.withValues(alpha: 0.3),
width: BorderWidth.thin,
),
),
2025-08-21 12:49:41 +05:30
child: Stack(
alignment: Alignment.center,
children: [
// Shimmer effect placeholder
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.conduitTheme.shimmerBase,
context.conduitTheme.shimmerHighlight,
context.conduitTheme.shimmerBase,
],
),
),
)
.animate(onPlay: (controller) => controller.repeat())
.shimmer(
duration: const Duration(milliseconds: 1500),
color: context.conduitTheme.shimmerHighlight.withValues(alpha: 0.3),
),
// Progress indicator overlay
CircularProgressIndicator(
color: context.conduitTheme.buttonPrimary,
strokeWidth: 2,
),
],
2025-08-20 18:39:30 +05:30
),
);
}
Widget _buildErrorState() {
return Container(
2025-08-21 12:49:41 +05:30
key: const ValueKey('error'),
2025-08-20 18:39:30 +05:30
constraints: widget.constraints ??
const BoxConstraints(
maxWidth: 300,
maxHeight: 150,
minHeight: 100,
minWidth: 200,
),
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
border: Border.all(
color: context.conduitTheme.error.withValues(alpha: 0.3),
width: BorderWidth.thin,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image_outlined,
color: context.conduitTheme.error,
size: 32,
),
const SizedBox(height: Spacing.xs),
Padding(
padding: const EdgeInsets.symmetric(horizontal: Spacing.sm),
child: Text(
_errorMessage!,
style: TextStyle(
color: context.conduitTheme.error,
fontSize: AppTypography.bodySmall,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
2025-08-21 12:49:41 +05:30
)
.animate()
.fadeIn(duration: const Duration(milliseconds: 200));
2025-08-20 18:39:30 +05:30
}
Widget _buildNetworkImage() {
2025-08-20 23:42:31 +05:30
// Get authentication headers if available
final api = ref.read(apiServiceProvider);
final authToken = ref.read(authTokenProvider3);
final headers = <String, String>{};
// Add auth token from unified auth provider
if (authToken != null && authToken.isNotEmpty) {
headers['Authorization'] = 'Bearer $authToken';
} else if (api?.serverConfig.apiKey != null && api!.serverConfig.apiKey!.isNotEmpty) {
// Fallback to API key from server config
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
}
// Add any custom headers from server config
if (api != null && api.serverConfig.customHeaders.isNotEmpty) {
headers.addAll(api.serverConfig.customHeaders);
}
2025-08-20 18:39:30 +05:30
final imageWidget = CachedNetworkImage(
2025-08-21 12:49:41 +05:30
key: ValueKey('image_${widget.attachmentId}'),
2025-08-20 18:39:30 +05:30
imageUrl: _cachedImageData!,
fit: BoxFit.cover,
2025-08-20 23:42:31 +05:30
httpHeaders: headers.isNotEmpty ? headers : null,
2025-08-21 12:49:41 +05:30
fadeInDuration: const Duration(milliseconds: 200),
fadeOutDuration: const Duration(milliseconds: 200),
placeholder: (context, url) => Container(
constraints: widget.constraints,
decoration: BoxDecoration(
color: context.conduitTheme.shimmerBase,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
),
2025-08-20 18:39:30 +05:30
errorWidget: (context, url, error) {
_errorMessage = error.toString();
return _buildErrorState();
},
);
return _wrapImage(imageWidget);
}
Widget _buildBase64Image() {
try {
// Extract base64 data from data URL if needed
String actualBase64;
if (_cachedImageData!.startsWith('data:')) {
final commaIndex = _cachedImageData!.indexOf(',');
if (commaIndex != -1) {
actualBase64 = _cachedImageData!.substring(commaIndex + 1);
} else {
throw Exception('Invalid data URL format');
}
} else {
actualBase64 = _cachedImageData!;
}
final imageBytes = base64.decode(actualBase64);
final imageWidget = Image.memory(
2025-08-21 12:49:41 +05:30
key: ValueKey('image_${widget.attachmentId}'),
2025-08-20 18:39:30 +05:30
imageBytes,
fit: BoxFit.cover,
2025-08-21 12:49:41 +05:30
gaplessPlayback: true, // Prevents flashing during rebuilds
2025-08-20 18:39:30 +05:30
errorBuilder: (context, error, stackTrace) {
_errorMessage = 'Failed to decode image';
return _buildErrorState();
},
);
return _wrapImage(imageWidget);
} catch (e) {
_errorMessage = 'Invalid image format';
return _buildErrorState();
}
}
Widget _wrapImage(Widget imageWidget) {
2025-08-21 12:49:41 +05:30
final wrappedImage = Container(
2025-08-20 18:39:30 +05:30
constraints: widget.constraints ??
const BoxConstraints(
maxWidth: 400,
maxHeight: 400,
),
margin: widget.isMarkdownFormat
? const EdgeInsets.symmetric(vertical: Spacing.sm)
: EdgeInsets.zero,
2025-08-21 12:49:41 +05:30
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
// Add subtle shadow for depth
boxShadow: [
BoxShadow(
color: context.conduitTheme.cardShadow.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap ?? () => _showFullScreenImage(context),
child: Hero(
tag: 'image_${widget.attachmentId}_${DateTime.now().millisecondsSinceEpoch}',
flightShuttleBuilder: (flightContext, animation, flightDirection,
fromHeroContext, toHeroContext) {
final hero = flightDirection == HeroFlightDirection.push
? fromHeroContext.widget as Hero
: toHeroContext.widget as Hero;
return FadeTransition(
opacity: animation,
child: hero.child,
);
},
child: imageWidget,
),
2025-08-20 18:39:30 +05:30
),
),
),
);
2025-08-21 12:49:41 +05:30
return wrappedImage;
2025-08-20 18:39:30 +05:30
}
void _showFullScreenImage(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (context) => FullScreenImageViewer(
imageData: _cachedImageData!,
tag: 'image_${widget.attachmentId}',
),
),
);
}
}
2025-08-20 23:42:31 +05:30
class FullScreenImageViewer extends ConsumerWidget {
2025-08-20 18:39:30 +05:30
final String imageData;
final String tag;
const FullScreenImageViewer({
super.key,
required this.imageData,
required this.tag,
});
@override
2025-08-20 23:42:31 +05:30
Widget build(BuildContext context, WidgetRef ref) {
2025-08-20 18:39:30 +05:30
Widget imageWidget;
if (imageData.startsWith('http')) {
2025-08-20 23:42:31 +05:30
// Get authentication headers if available
final api = ref.read(apiServiceProvider);
final authToken = ref.read(authTokenProvider3);
final headers = <String, String>{};
// Add auth token from unified auth provider
if (authToken != null && authToken.isNotEmpty) {
headers['Authorization'] = 'Bearer $authToken';
} else if (api?.serverConfig.apiKey != null && api!.serverConfig.apiKey!.isNotEmpty) {
// Fallback to API key from server config
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
}
// Add any custom headers from server config
if (api != null && api.serverConfig.customHeaders.isNotEmpty) {
headers.addAll(api.serverConfig.customHeaders);
}
2025-08-20 18:39:30 +05:30
imageWidget = CachedNetworkImage(
imageUrl: imageData,
fit: BoxFit.contain,
2025-08-20 23:42:31 +05:30
httpHeaders: headers.isNotEmpty ? headers : null,
2025-08-20 18:39:30 +05:30
placeholder: (context, url) => Center(
child: CircularProgressIndicator(
color: context.conduitTheme.buttonPrimary,
),
),
errorWidget: (context, url, error) => Center(
child: Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 48,
),
),
);
} else {
try {
String actualBase64;
if (imageData.startsWith('data:')) {
final commaIndex = imageData.indexOf(',');
actualBase64 = imageData.substring(commaIndex + 1);
} else {
actualBase64 = imageData;
}
final imageBytes = base64.decode(actualBase64);
imageWidget = Image.memory(
imageBytes,
fit: BoxFit.contain,
);
} catch (e) {
imageWidget = Center(
child: Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 48,
),
);
}
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Center(
child: Hero(
tag: tag,
child: InteractiveViewer(
minScale: 0.5,
maxScale: 5.0,
child: imageWidget,
),
),
),
Positioned(
top: MediaQuery.of(context).padding.top + 16,
right: 16,
child: IconButton(
icon: const Icon(
Icons.close,
color: Colors.white,
size: 28,
),
onPressed: () => Navigator.of(context).pop(),
),
),
],
),
);
}
}