diff --git a/lib/features/chat/providers/attachment_cache_provider.dart b/lib/features/chat/providers/attachment_cache_provider.dart new file mode 100644 index 0000000..de49e2a --- /dev/null +++ b/lib/features/chat/providers/attachment_cache_provider.dart @@ -0,0 +1,139 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/utils/debug_logger.dart'; + +// Global attachment cache state +class AttachmentCacheState { + final Map imageDataCache; + final Map loadingStates; + final Map errorStates; + + AttachmentCacheState({ + required this.imageDataCache, + required this.loadingStates, + required this.errorStates, + }); + + AttachmentCacheState copyWith({ + Map? imageDataCache, + Map? loadingStates, + Map? errorStates, + }) { + return AttachmentCacheState( + imageDataCache: imageDataCache ?? this.imageDataCache, + loadingStates: loadingStates ?? this.loadingStates, + errorStates: errorStates ?? this.errorStates, + ); + } +} + +class AttachmentCacheNotifier extends StateNotifier { + AttachmentCacheNotifier() + : super(AttachmentCacheState( + imageDataCache: {}, + loadingStates: {}, + errorStates: {}, + )); + + void cacheImageData(String attachmentId, String imageData) { + DebugLogger.log('Caching image data for: $attachmentId'); + state = state.copyWith( + imageDataCache: { + ...state.imageDataCache, + attachmentId: imageData, + }, + ); + + // Limit cache size to prevent memory issues + if (state.imageDataCache.length > 100) { + final newCache = Map.from(state.imageDataCache); + final keysToRemove = newCache.keys.take(20).toList(); + for (final key in keysToRemove) { + newCache.remove(key); + state.loadingStates.remove(key); + state.errorStates.remove(key); + } + state = state.copyWith(imageDataCache: newCache); + } + } + + String? getCachedImageData(String attachmentId) { + return state.imageDataCache[attachmentId]; + } + + void setLoadingState(String attachmentId, bool isLoading) { + state = state.copyWith( + loadingStates: { + ...state.loadingStates, + attachmentId: isLoading, + }, + ); + } + + bool isLoading(String attachmentId) { + return state.loadingStates[attachmentId] ?? false; + } + + void setErrorState(String attachmentId, String? error) { + if (error == null) { + final newErrorStates = Map.from(state.errorStates); + newErrorStates.remove(attachmentId); + state = state.copyWith(errorStates: newErrorStates); + } else { + state = state.copyWith( + errorStates: { + ...state.errorStates, + attachmentId: error, + }, + ); + } + } + + String? getErrorState(String attachmentId) { + return state.errorStates[attachmentId]; + } + + void clearCache() { + state = AttachmentCacheState( + imageDataCache: {}, + loadingStates: {}, + errorStates: {}, + ); + } + + void clearAttachmentCache(String attachmentId) { + final newImageCache = Map.from(state.imageDataCache); + final newLoadingStates = Map.from(state.loadingStates); + final newErrorStates = Map.from(state.errorStates); + + newImageCache.remove(attachmentId); + newLoadingStates.remove(attachmentId); + newErrorStates.remove(attachmentId); + + state = AttachmentCacheState( + imageDataCache: newImageCache, + loadingStates: newLoadingStates, + errorStates: newErrorStates, + ); + } +} + +final attachmentCacheProvider = + StateNotifierProvider((ref) { + return AttachmentCacheNotifier(); +}); + +// Helper providers for easier access +final cachedImageDataProvider = Provider.family((ref, attachmentId) { + final cache = ref.watch(attachmentCacheProvider); + return cache.imageDataCache[attachmentId]; +}); + +final attachmentLoadingStateProvider = Provider.family((ref, attachmentId) { + final cache = ref.watch(attachmentCacheProvider); + return cache.loadingStates[attachmentId] ?? false; +}); + +final attachmentErrorStateProvider = Provider.family((ref, attachmentId) { + final cache = ref.watch(attachmentCacheProvider); + return cache.errorStates[attachmentId]; +}); \ No newline at end of file diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 7322c9b..05a5dee 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -855,84 +855,107 @@ class _ChatPageState extends ConsumerState { ); final isLoadingConversation = ref.watch(isLoadingConversationProvider); - if (isLoadingConversation && messages.isEmpty) { - // Show message skeletons during conversation load - return ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.fromLTRB( - Spacing.lg, - Spacing.md, - Spacing.lg, - Spacing.lg, - ), - itemCount: 6, - itemBuilder: (context, index) { - final isUser = index.isOdd; - return Align( - alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: Spacing.md), - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.82, + // Use AnimatedSwitcher for smooth transition between loading and loaded states + return AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: isLoadingConversation && messages.isEmpty + ? _buildLoadingMessagesList() + : _buildActualMessagesList(messages), + ); + } + + Widget _buildLoadingMessagesList() { + return ListView.builder( + key: const ValueKey('loading_messages'), + controller: _scrollController, + padding: const EdgeInsets.fromLTRB( + Spacing.lg, + Spacing.md, + Spacing.lg, + Spacing.lg, + ), + physics: const NeverScrollableScrollPhysics(), // Prevent scrolling during load + itemCount: 6, + itemBuilder: (context, index) { + final isUser = index.isOdd; + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: Spacing.md), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.82, + ), + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: isUser + ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.15) + : context.conduitTheme.cardBackground, + borderRadius: BorderRadius.circular( + AppBorderRadius.messageBubble, ), - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: isUser - ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.15) - : context.conduitTheme.cardBackground, - borderRadius: BorderRadius.circular( - AppBorderRadius.messageBubble, - ), - border: Border.all( - color: context.conduitTheme.cardBorder, - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.messageBubble, + border: Border.all( + color: context.conduitTheme.cardBorder, + width: BorderWidth.regular, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 14, - width: index % 3 == 0 ? 140 : 220, - decoration: BoxDecoration( - color: context.conduitTheme.shimmerBase, - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - ).animate().shimmer(duration: AnimationDuration.slow), + boxShadow: ConduitShadows.messageBubble, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 14, + width: index % 3 == 0 ? 140 : 220, + decoration: BoxDecoration( + color: context.conduitTheme.shimmerBase, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ).animate().shimmer(duration: AnimationDuration.slow), + const SizedBox(height: Spacing.xs), + Container( + height: 14, + width: double.infinity, + decoration: BoxDecoration( + color: context.conduitTheme.shimmerBase, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + ).animate().shimmer(duration: AnimationDuration.slow), + if (index % 3 != 0) ...[ const SizedBox(height: Spacing.xs), Container( height: 14, - width: double.infinity, + width: index % 2 == 0 ? 180 : 120, decoration: BoxDecoration( color: context.conduitTheme.shimmerBase, borderRadius: BorderRadius.circular(AppBorderRadius.xs), ), ).animate().shimmer(duration: AnimationDuration.slow), - if (index % 3 != 0) ...[ - const SizedBox(height: Spacing.xs), - Container( - height: 14, - width: index % 2 == 0 ? 180 : 120, - decoration: BoxDecoration( - color: context.conduitTheme.shimmerBase, - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - ).animate().shimmer(duration: AnimationDuration.slow), - ], ], - ), + ], ), - ); - }, - ); - } + ), + ); + }, + ); + } + Widget _buildActualMessagesList(List messages) { if (messages.isEmpty) { - return _buildEmptyState(theme); + return _buildEmptyState(Theme.of(context)); } return OptimizedList( + key: const ValueKey('actual_messages'), scrollController: _scrollController, items: messages, padding: const EdgeInsets.fromLTRB( diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 274d561..4000a14 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -377,34 +377,38 @@ class _AssistantMessageWidgetState extends ConsumerState final imageCount = widget.message.attachmentIds!.length; // Display images in a clean, modern layout for assistant messages - if (imageCount == 1) { - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: EnhancedImageAttachment( - attachmentId: widget.message.attachmentIds![0], - isMarkdownFormat: true, - constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400), - ), - ); - } else { - return Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: widget.message.attachmentIds!.map((attachmentId) { - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: EnhancedImageAttachment( - attachmentId: attachmentId, - isMarkdownFormat: true, - constraints: BoxConstraints( - maxWidth: imageCount == 2 ? 245 : 160, - maxHeight: imageCount == 2 ? 245 : 160, + // Use AnimatedSwitcher for smooth transitions when loading + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOut, + child: imageCount == 1 + ? Container( + key: ValueKey('single_image_${widget.message.attachmentIds![0]}'), + child: EnhancedImageAttachment( + attachmentId: widget.message.attachmentIds![0], + isMarkdownFormat: true, + constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400), + disableAnimation: widget.isStreaming, // Disable animation during streaming ), + ) + : Wrap( + key: ValueKey('multi_images_${widget.message.attachmentIds!.join('_')}'), + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: widget.message.attachmentIds!.map((attachmentId) { + return EnhancedImageAttachment( + key: ValueKey('attachment_$attachmentId'), + attachmentId: attachmentId, + isMarkdownFormat: true, + constraints: BoxConstraints( + maxWidth: imageCount == 2 ? 245 : 160, + maxHeight: imageCount == 2 ? 245 : 160, + ), + disableAnimation: widget.isStreaming, // Disable animation during streaming + ); + }).toList(), ), - ); - }).toList(), - ); - } + ); } Widget _buildGeneratedImages() { @@ -424,40 +428,48 @@ class _AssistantMessageWidgetState extends ConsumerState final imageCount = imageFiles.length; // Display generated images using EnhancedImageAttachment for consistency - if (imageCount == 1) { - final imageUrl = imageFiles[0]['url'] as String?; - if (imageUrl == null) return const SizedBox.shrink(); - - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: EnhancedImageAttachment( - attachmentId: imageUrl, // Pass URL directly as it handles URLs - isMarkdownFormat: true, - constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400), - ), - ); - } else { - return Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: imageFiles.map((file) { - final imageUrl = file['url'] as String?; - if (imageUrl == null) return const SizedBox.shrink(); - - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: EnhancedImageAttachment( - attachmentId: imageUrl, // Pass URL directly - isMarkdownFormat: true, - constraints: BoxConstraints( - maxWidth: imageCount == 2 ? 245 : 160, - maxHeight: imageCount == 2 ? 245 : 160, + // Use AnimatedSwitcher for smooth transitions + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOut, + child: imageCount == 1 + ? Container( + key: ValueKey('gen_single_${imageFiles[0]['url']}'), + child: Builder( + builder: (context) { + final imageUrl = imageFiles[0]['url'] as String?; + if (imageUrl == null) return const SizedBox.shrink(); + + return EnhancedImageAttachment( + attachmentId: imageUrl, // Pass URL directly as it handles URLs + isMarkdownFormat: true, + constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400), + disableAnimation: widget.isStreaming, // Disable animation during streaming + ); + }, ), + ) + : Wrap( + key: ValueKey('gen_multi_${imageFiles.map((f) => f['url']).join('_')}'), + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: imageFiles.map((file) { + final imageUrl = file['url'] as String?; + if (imageUrl == null) return const SizedBox.shrink(); + + return EnhancedImageAttachment( + key: ValueKey('gen_attachment_$imageUrl'), + attachmentId: imageUrl, // Pass URL directly + isMarkdownFormat: true, + constraints: BoxConstraints( + maxWidth: imageCount == 2 ? 245 : 160, + maxHeight: imageCount == 2 ? 245 : 160, + ), + disableAnimation: widget.isStreaming, // Disable animation during streaming + ); + }).toList(), ), - ); - }).toList(), - ); - } + ); } Widget _buildTypingIndicator() { diff --git a/lib/features/chat/widgets/enhanced_image_attachment.dart b/lib/features/chat/widgets/enhanced_image_attachment.dart index 9f68ea5..9613353 100644 --- a/lib/features/chat/widgets/enhanced_image_attachment.dart +++ b/lib/features/chat/widgets/enhanced_image_attachment.dart @@ -2,12 +2,15 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../core/providers/app_providers.dart'; import '../../auth/providers/unified_auth_providers.dart'; -// Global cache for image data to prevent reloading +// Simple global cache to prevent reloading final _globalImageCache = {}; +final _globalLoadingStates = {}; +final _globalErrorStates = {}; class EnhancedImageAttachment extends ConsumerStatefulWidget { final String attachmentId; @@ -15,6 +18,7 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget { final VoidCallback? onTap; final BoxConstraints? constraints; final bool isUserMessage; + final bool disableAnimation; const EnhancedImageAttachment({ super.key, @@ -23,6 +27,7 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget { this.onTap, this.constraints, this.isUserMessage = false, + this.disableAnimation = false, }); @override @@ -32,10 +37,13 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget { class _EnhancedImageAttachmentState extends ConsumerState - with AutomaticKeepAliveClientMixin { + with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { String? _cachedImageData; bool _isLoading = true; String? _errorMessage; + late AnimationController _animationController; + late Animation _fadeAnimation; + bool _hasShownContent = false; @override bool get wantKeepAlive => true; @@ -43,9 +51,23 @@ class _EnhancedImageAttachmentState @override void initState() { super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _fadeAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ); _loadImage(); } + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + Future _loadImage() async { // Check global cache first if (_globalImageCache.containsKey(widget.attachmentId)) { @@ -53,20 +75,43 @@ class _EnhancedImageAttachmentState setState(() { _cachedImageData = _globalImageCache[widget.attachmentId]; _isLoading = false; + _hasShownContent = true; + }); + if (!widget.disableAnimation) { + _animationController.forward(); + } + } + 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; + // 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; + _globalLoadingStates[widget.attachmentId] = false; if (mounted) { setState(() { _cachedImageData = widget.attachmentId; _isLoading = false; + _hasShownContent = true; }); + if (!widget.disableAnimation) { + _animationController.forward(); + } } return; } @@ -78,18 +123,26 @@ class _EnhancedImageAttachmentState if (api != null) { final fullUrl = api.baseUrl + widget.attachmentId; _globalImageCache[widget.attachmentId] = fullUrl; + _globalLoadingStates[widget.attachmentId] = false; if (mounted) { setState(() { _cachedImageData = fullUrl; _isLoading = false; + _hasShownContent = true; }); + if (!widget.disableAnimation) { + _animationController.forward(); + } } return; } else { // If API service is not available, show error + final error = 'Unable to load image: API service not available'; + _globalErrorStates[widget.attachmentId] = error; + _globalLoadingStates[widget.attachmentId] = false; if (mounted) { setState(() { - _errorMessage = 'Unable to load image: API service not available'; + _errorMessage = error; _isLoading = false; }); } @@ -99,9 +152,12 @@ class _EnhancedImageAttachmentState final api = ref.read(apiServiceProvider); if (api == null) { + final error = 'API service not available'; + _globalErrorStates[widget.attachmentId] = error; + _globalLoadingStates[widget.attachmentId] = false; if (mounted) { setState(() { - _errorMessage = 'API service not available'; + _errorMessage = error; _isLoading = false; }); } @@ -115,9 +171,12 @@ class _EnhancedImageAttachmentState final ext = fileName.toLowerCase().split('.').last; if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].contains(ext)) { + final error = 'Not an image file: $fileName'; + _globalErrorStates[widget.attachmentId] = error; + _globalLoadingStates[widget.attachmentId] = false; if (mounted) { setState(() { - _errorMessage = 'Not an image file: $fileName'; + _errorMessage = error; _isLoading = false; }); } @@ -129,22 +188,33 @@ class _EnhancedImageAttachmentState // Cache globally _globalImageCache[widget.attachmentId] = fileContent; + _globalLoadingStates[widget.attachmentId] = false; // Limit cache size if (_globalImageCache.length > 50) { - _globalImageCache.remove(_globalImageCache.keys.first); + final firstKey = _globalImageCache.keys.first; + _globalImageCache.remove(firstKey); + _globalLoadingStates.remove(firstKey); + _globalErrorStates.remove(firstKey); } if (mounted) { setState(() { _cachedImageData = fileContent; _isLoading = false; + _hasShownContent = true; }); + if (!widget.disableAnimation) { + _animationController.forward(); + } } } catch (e) { + final error = 'Failed to load image: ${e.toString()}'; + _globalErrorStates[widget.attachmentId] = error; + _globalLoadingStates[widget.attachmentId] = false; if (mounted) { setState(() { - _errorMessage = 'Failed to load image: ${e.toString()}'; + _errorMessage = error; _isLoading = false; }); } @@ -165,6 +235,25 @@ class _EnhancedImageAttachmentState Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin + // 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: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: _buildContent(), + ); + } + + Widget _buildContent() { if (_isLoading) { return _buildLoadingState(); } @@ -178,22 +267,36 @@ class _EnhancedImageAttachmentState } // Handle different image data formats + Widget imageWidget; if (_cachedImageData!.startsWith('http')) { - return _buildNetworkImage(); + imageWidget = _buildNetworkImage(); } else { - return _buildBase64Image(); + imageWidget = _buildBase64Image(); } + + // Apply fade animation only when first showing content + if (!widget.disableAnimation && _hasShownContent) { + return FadeTransition( + opacity: _fadeAnimation, + child: imageWidget, + ); + } + + return imageWidget; } Widget _buildLoadingState() { + final constraints = widget.constraints ?? + const BoxConstraints( + maxWidth: 300, + maxHeight: 300, + minHeight: 150, + minWidth: 200, + ); + return Container( - constraints: widget.constraints ?? - const BoxConstraints( - maxWidth: 300, - maxHeight: 300, - minHeight: 150, - minWidth: 200, - ), + key: const ValueKey('loading'), + constraints: constraints, margin: const EdgeInsets.only(bottom: Spacing.xs), decoration: BoxDecoration( color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.5), @@ -203,17 +306,42 @@ class _EnhancedImageAttachmentState width: BorderWidth.thin, ), ), - child: Center( - child: CircularProgressIndicator( - color: context.conduitTheme.buttonPrimary, - strokeWidth: 2, - ), + 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, + ), + ], ), ); } Widget _buildErrorState() { return Container( + key: const ValueKey('error'), constraints: widget.constraints ?? const BoxConstraints( maxWidth: 300, @@ -254,7 +382,9 @@ class _EnhancedImageAttachmentState ), ], ), - ); + ) + .animate() + .fadeIn(duration: const Duration(milliseconds: 200)); } Widget _buildNetworkImage() { @@ -277,10 +407,19 @@ class _EnhancedImageAttachmentState } final imageWidget = CachedNetworkImage( + key: ValueKey('image_${widget.attachmentId}'), imageUrl: _cachedImageData!, fit: BoxFit.cover, httpHeaders: headers.isNotEmpty ? headers : null, - placeholder: (context, url) => _buildLoadingState(), + 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), + ), + ), errorWidget: (context, url, error) { _errorMessage = error.toString(); return _buildErrorState(); @@ -307,8 +446,10 @@ class _EnhancedImageAttachmentState 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 = 'Failed to decode image'; return _buildErrorState(); @@ -323,7 +464,7 @@ class _EnhancedImageAttachmentState } Widget _wrapImage(Widget imageWidget) { - return Container( + final wrappedImage = Container( constraints: widget.constraints ?? const BoxConstraints( maxWidth: 400, @@ -332,17 +473,43 @@ class _EnhancedImageAttachmentState margin: widget.isMarkdownFormat ? const EdgeInsets.symmetric(vertical: Spacing.sm) : EdgeInsets.zero, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: widget.onTap ?? () => _showFullScreenImage(context), - child: Hero( - tag: 'image_${widget.attachmentId}', - child: imageWidget, + 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, + ), ), ), ), ); + + return wrappedImage; } void _showFullScreenImage(BuildContext context) { diff --git a/lib/features/chat/widgets/user_message_bubble.dart b/lib/features/chat/widgets/user_message_bubble.dart index 613066c..9a26c99 100644 --- a/lib/features/chat/widgets/user_message_bubble.dart +++ b/lib/features/chat/widgets/user_message_bubble.dart @@ -62,18 +62,40 @@ class _UserMessageBubbleState extends ConsumerState final imageCount = widget.message.attachmentIds!.length; - // iMessage-style image layout + // iMessage-style image layout with AnimatedSwitcher for smooth transitions + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOut, + child: _buildImageLayout(imageCount), + ); + } + + Widget _buildImageLayout(int imageCount) { if (imageCount == 1) { // Single image - larger display return Row( + key: ValueKey('user_single_${widget.message.attachmentIds![0]}'), mainAxisAlignment: MainAxisAlignment.end, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), - child: EnhancedImageAttachment( - attachmentId: widget.message.attachmentIds![0], - isUserMessage: true, - constraints: const BoxConstraints(maxWidth: 280, maxHeight: 350), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), + boxShadow: [ + BoxShadow( + color: context.conduitTheme.cardShadow.withValues(alpha: 0.1), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), + child: EnhancedImageAttachment( + attachmentId: widget.message.attachmentIds![0], + isUserMessage: true, + constraints: const BoxConstraints(maxWidth: 280, maxHeight: 350), + disableAnimation: widget.isStreaming, + ), ), ), ], @@ -81,29 +103,40 @@ class _UserMessageBubbleState extends ConsumerState } else if (imageCount == 2) { // Two images side by side return Row( + key: ValueKey('user_double_${widget.message.attachmentIds!.join('_')}'), mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Row( mainAxisSize: MainAxisSize.min, - children: widget.message.attachmentIds!.map((attachmentId) { + children: widget.message.attachmentIds!.asMap().entries.map((entry) { + final index = entry.key; + final attachmentId = entry.value; return Padding( - padding: EdgeInsets.only( - left: attachmentId == widget.message.attachmentIds!.first - ? 0 - : Spacing.xs, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular( - AppBorderRadius.messageBubble, + padding: EdgeInsets.only(left: index == 0 ? 0 : Spacing.xs), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), + boxShadow: [ + BoxShadow( + color: context.conduitTheme.cardShadow.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], ), - child: EnhancedImageAttachment( - attachmentId: attachmentId, - isUserMessage: true, - constraints: const BoxConstraints( - maxWidth: 135, - maxHeight: 180, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), + child: EnhancedImageAttachment( + key: ValueKey('user_attachment_$attachmentId'), + attachmentId: attachmentId, + isUserMessage: true, + constraints: const BoxConstraints( + maxWidth: 135, + maxHeight: 180, + ), + disableAnimation: widget.isStreaming, ), ), ), @@ -116,6 +149,7 @@ class _UserMessageBubbleState extends ConsumerState } else { // Grid layout for 3+ images return Row( + key: ValueKey('user_grid_${widget.message.attachmentIds!.join('_')}'), mainAxisAlignment: MainAxisAlignment.end, children: [ Flexible( @@ -126,14 +160,28 @@ class _UserMessageBubbleState extends ConsumerState spacing: Spacing.xs, runSpacing: Spacing.xs, children: widget.message.attachmentIds!.map((attachmentId) { - return ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: EnhancedImageAttachment( - attachmentId: attachmentId, - isUserMessage: true, - constraints: BoxConstraints( - maxWidth: imageCount == 3 ? 135 : 90, - maxHeight: imageCount == 3 ? 135 : 90, + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + boxShadow: [ + BoxShadow( + color: context.conduitTheme.cardShadow.withValues(alpha: 0.06), + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: EnhancedImageAttachment( + key: ValueKey('user_grid_attachment_$attachmentId'), + attachmentId: attachmentId, + isUserMessage: true, + constraints: BoxConstraints( + maxWidth: imageCount == 3 ? 135 : 90, + maxHeight: imageCount == 3 ? 135 : 90, + ), + disableAnimation: widget.isStreaming, ), ), );