refactor: image attachments

This commit is contained in:
cogwheel0
2025-08-21 12:49:41 +05:30
parent bc2f60e685
commit c3fe819d7e
5 changed files with 569 additions and 180 deletions

View File

@@ -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<String, String> imageDataCache;
final Map<String, bool> loadingStates;
final Map<String, String> errorStates;
AttachmentCacheState({
required this.imageDataCache,
required this.loadingStates,
required this.errorStates,
});
AttachmentCacheState copyWith({
Map<String, String>? imageDataCache,
Map<String, bool>? loadingStates,
Map<String, String>? errorStates,
}) {
return AttachmentCacheState(
imageDataCache: imageDataCache ?? this.imageDataCache,
loadingStates: loadingStates ?? this.loadingStates,
errorStates: errorStates ?? this.errorStates,
);
}
}
class AttachmentCacheNotifier extends StateNotifier<AttachmentCacheState> {
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<String, String>.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<String, String>.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<String, String>.from(state.imageDataCache);
final newLoadingStates = Map<String, bool>.from(state.loadingStates);
final newErrorStates = Map<String, String>.from(state.errorStates);
newImageCache.remove(attachmentId);
newLoadingStates.remove(attachmentId);
newErrorStates.remove(attachmentId);
state = AttachmentCacheState(
imageDataCache: newImageCache,
loadingStates: newLoadingStates,
errorStates: newErrorStates,
);
}
}
final attachmentCacheProvider =
StateNotifierProvider<AttachmentCacheNotifier, AttachmentCacheState>((ref) {
return AttachmentCacheNotifier();
});
// Helper providers for easier access
final cachedImageDataProvider = Provider.family<String?, String>((ref, attachmentId) {
final cache = ref.watch(attachmentCacheProvider);
return cache.imageDataCache[attachmentId];
});
final attachmentLoadingStateProvider = Provider.family<bool, String>((ref, attachmentId) {
final cache = ref.watch(attachmentCacheProvider);
return cache.loadingStates[attachmentId] ?? false;
});
final attachmentErrorStateProvider = Provider.family<String?, String>((ref, attachmentId) {
final cache = ref.watch(attachmentCacheProvider);
return cache.errorStates[attachmentId];
});

View File

@@ -855,84 +855,107 @@ class _ChatPageState extends ConsumerState<ChatPage> {
); );
final isLoadingConversation = ref.watch(isLoadingConversationProvider); final isLoadingConversation = ref.watch(isLoadingConversationProvider);
if (isLoadingConversation && messages.isEmpty) { // Use AnimatedSwitcher for smooth transition between loading and loaded states
// Show message skeletons during conversation load return AnimatedSwitcher(
return ListView.builder( duration: const Duration(milliseconds: 400),
controller: _scrollController, switchInCurve: Curves.easeInOut,
padding: const EdgeInsets.fromLTRB( switchOutCurve: Curves.easeInOut,
Spacing.lg, layoutBuilder: (currentChild, previousChildren) {
Spacing.md, return Stack(
Spacing.lg, alignment: Alignment.topCenter,
Spacing.lg, children: <Widget>[
), ...previousChildren,
itemCount: 6, if (currentChild != null) currentChild,
itemBuilder: (context, index) { ],
final isUser = index.isOdd; );
return Align( },
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: isLoadingConversation && messages.isEmpty
child: Container( ? _buildLoadingMessagesList()
margin: const EdgeInsets.only(bottom: Spacing.md), : _buildActualMessagesList(messages),
constraints: BoxConstraints( );
maxWidth: MediaQuery.of(context).size.width * 0.82, }
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), border: Border.all(
decoration: BoxDecoration( color: context.conduitTheme.cardBorder,
color: isUser width: BorderWidth.regular,
? 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,
), ),
child: Column( boxShadow: ConduitShadows.messageBubble,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ child: Column(
Container( crossAxisAlignment: CrossAxisAlignment.start,
height: 14, children: [
width: index % 3 == 0 ? 140 : 220, Container(
decoration: BoxDecoration( height: 14,
color: context.conduitTheme.shimmerBase, width: index % 3 == 0 ? 140 : 220,
borderRadius: BorderRadius.circular(AppBorderRadius.xs), decoration: BoxDecoration(
), color: context.conduitTheme.shimmerBase,
).animate().shimmer(duration: AnimationDuration.slow), 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), const SizedBox(height: Spacing.xs),
Container( Container(
height: 14, height: 14,
width: double.infinity, width: index % 2 == 0 ? 180 : 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.conduitTheme.shimmerBase, color: context.conduitTheme.shimmerBase,
borderRadius: BorderRadius.circular(AppBorderRadius.xs), borderRadius: BorderRadius.circular(AppBorderRadius.xs),
), ),
).animate().shimmer(duration: AnimationDuration.slow), ).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<ChatMessage> messages) {
if (messages.isEmpty) { if (messages.isEmpty) {
return _buildEmptyState(theme); return _buildEmptyState(Theme.of(context));
} }
return OptimizedList<ChatMessage>( return OptimizedList<ChatMessage>(
key: const ValueKey('actual_messages'),
scrollController: _scrollController, scrollController: _scrollController,
items: messages, items: messages,
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(

View File

@@ -377,34 +377,38 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
final imageCount = widget.message.attachmentIds!.length; final imageCount = widget.message.attachmentIds!.length;
// Display images in a clean, modern layout for assistant messages // Display images in a clean, modern layout for assistant messages
if (imageCount == 1) { // Use AnimatedSwitcher for smooth transitions when loading
return ClipRRect( return AnimatedSwitcher(
borderRadius: BorderRadius.circular(AppBorderRadius.md), duration: const Duration(milliseconds: 300),
child: EnhancedImageAttachment( switchInCurve: Curves.easeInOut,
attachmentId: widget.message.attachmentIds![0], child: imageCount == 1
isMarkdownFormat: true, ? Container(
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400), key: ValueKey('single_image_${widget.message.attachmentIds![0]}'),
), child: EnhancedImageAttachment(
); attachmentId: widget.message.attachmentIds![0],
} else { isMarkdownFormat: true,
return Wrap( constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400),
spacing: Spacing.sm, disableAnimation: widget.isStreaming, // Disable animation during streaming
runSpacing: Spacing.sm,
children: widget.message.attachmentIds!.map<Widget>((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,
), ),
)
: Wrap(
key: ValueKey('multi_images_${widget.message.attachmentIds!.join('_')}'),
spacing: Spacing.sm,
runSpacing: Spacing.sm,
children: widget.message.attachmentIds!.map<Widget>((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() { Widget _buildGeneratedImages() {
@@ -424,40 +428,48 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
final imageCount = imageFiles.length; final imageCount = imageFiles.length;
// Display generated images using EnhancedImageAttachment for consistency // Display generated images using EnhancedImageAttachment for consistency
if (imageCount == 1) { // Use AnimatedSwitcher for smooth transitions
final imageUrl = imageFiles[0]['url'] as String?; return AnimatedSwitcher(
if (imageUrl == null) return const SizedBox.shrink(); duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeInOut,
return ClipRRect( child: imageCount == 1
borderRadius: BorderRadius.circular(AppBorderRadius.md), ? Container(
child: EnhancedImageAttachment( key: ValueKey('gen_single_${imageFiles[0]['url']}'),
attachmentId: imageUrl, // Pass URL directly as it handles URLs child: Builder(
isMarkdownFormat: true, builder: (context) {
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400), final imageUrl = imageFiles[0]['url'] as String?;
), if (imageUrl == null) return const SizedBox.shrink();
);
} else { return EnhancedImageAttachment(
return Wrap( attachmentId: imageUrl, // Pass URL directly as it handles URLs
spacing: Spacing.sm, isMarkdownFormat: true,
runSpacing: Spacing.sm, constraints: const BoxConstraints(maxWidth: 500, maxHeight: 400),
children: imageFiles.map<Widget>((file) { disableAnimation: widget.isStreaming, // Disable animation during streaming
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,
), ),
)
: Wrap(
key: ValueKey('gen_multi_${imageFiles.map((f) => f['url']).join('_')}'),
spacing: Spacing.sm,
runSpacing: Spacing.sm,
children: imageFiles.map<Widget>((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() { Widget _buildTypingIndicator() {

View File

@@ -2,12 +2,15 @@ import 'dart:convert';
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';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/theme/theme_extensions.dart';
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../../auth/providers/unified_auth_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 = <String, String>{}; final _globalImageCache = <String, String>{};
final _globalLoadingStates = <String, bool>{};
final _globalErrorStates = <String, String>{};
class EnhancedImageAttachment extends ConsumerStatefulWidget { class EnhancedImageAttachment extends ConsumerStatefulWidget {
final String attachmentId; final String attachmentId;
@@ -15,6 +18,7 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final BoxConstraints? constraints; final BoxConstraints? constraints;
final bool isUserMessage; final bool isUserMessage;
final bool disableAnimation;
const EnhancedImageAttachment({ const EnhancedImageAttachment({
super.key, super.key,
@@ -23,6 +27,7 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget {
this.onTap, this.onTap,
this.constraints, this.constraints,
this.isUserMessage = false, this.isUserMessage = false,
this.disableAnimation = false,
}); });
@override @override
@@ -32,10 +37,13 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget {
class _EnhancedImageAttachmentState class _EnhancedImageAttachmentState
extends ConsumerState<EnhancedImageAttachment> extends ConsumerState<EnhancedImageAttachment>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
String? _cachedImageData; String? _cachedImageData;
bool _isLoading = true; bool _isLoading = true;
String? _errorMessage; String? _errorMessage;
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
bool _hasShownContent = false;
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -43,9 +51,23 @@ class _EnhancedImageAttachmentState
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
_loadImage(); _loadImage();
} }
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
Future<void> _loadImage() async { Future<void> _loadImage() async {
// Check global cache first // Check global cache first
if (_globalImageCache.containsKey(widget.attachmentId)) { if (_globalImageCache.containsKey(widget.attachmentId)) {
@@ -53,20 +75,43 @@ class _EnhancedImageAttachmentState
setState(() { setState(() {
_cachedImageData = _globalImageCache[widget.attachmentId]; _cachedImageData = _globalImageCache[widget.attachmentId];
_isLoading = false; _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; return;
} }
// Set loading state
_globalLoadingStates[widget.attachmentId] = true;
// Check if this is already a data URL or base64 image // Check if this is already a data URL or base64 image
if (widget.attachmentId.startsWith('data:') || if (widget.attachmentId.startsWith('data:') ||
widget.attachmentId.startsWith('http')) { widget.attachmentId.startsWith('http')) {
_globalImageCache[widget.attachmentId] = widget.attachmentId; _globalImageCache[widget.attachmentId] = widget.attachmentId;
_globalLoadingStates[widget.attachmentId] = false;
if (mounted) { if (mounted) {
setState(() { setState(() {
_cachedImageData = widget.attachmentId; _cachedImageData = widget.attachmentId;
_isLoading = false; _isLoading = false;
_hasShownContent = true;
}); });
if (!widget.disableAnimation) {
_animationController.forward();
}
} }
return; return;
} }
@@ -78,18 +123,26 @@ class _EnhancedImageAttachmentState
if (api != null) { if (api != null) {
final fullUrl = api.baseUrl + widget.attachmentId; final fullUrl = api.baseUrl + widget.attachmentId;
_globalImageCache[widget.attachmentId] = fullUrl; _globalImageCache[widget.attachmentId] = fullUrl;
_globalLoadingStates[widget.attachmentId] = false;
if (mounted) { if (mounted) {
setState(() { setState(() {
_cachedImageData = fullUrl; _cachedImageData = fullUrl;
_isLoading = false; _isLoading = false;
_hasShownContent = true;
}); });
if (!widget.disableAnimation) {
_animationController.forward();
}
} }
return; return;
} else { } else {
// If API service is not available, show error // 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) { if (mounted) {
setState(() { setState(() {
_errorMessage = 'Unable to load image: API service not available'; _errorMessage = error;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -99,9 +152,12 @@ class _EnhancedImageAttachmentState
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
if (api == null) { if (api == null) {
final error = 'API service not available';
_globalErrorStates[widget.attachmentId] = error;
_globalLoadingStates[widget.attachmentId] = false;
if (mounted) { if (mounted) {
setState(() { setState(() {
_errorMessage = 'API service not available'; _errorMessage = error;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -115,9 +171,12 @@ class _EnhancedImageAttachmentState
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 = 'Not an image file: $fileName';
_globalErrorStates[widget.attachmentId] = error;
_globalLoadingStates[widget.attachmentId] = false;
if (mounted) { if (mounted) {
setState(() { setState(() {
_errorMessage = 'Not an image file: $fileName'; _errorMessage = error;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -129,22 +188,33 @@ class _EnhancedImageAttachmentState
// Cache globally // Cache globally
_globalImageCache[widget.attachmentId] = fileContent; _globalImageCache[widget.attachmentId] = fileContent;
_globalLoadingStates[widget.attachmentId] = false;
// Limit cache size // Limit cache size
if (_globalImageCache.length > 50) { 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) { if (mounted) {
setState(() { setState(() {
_cachedImageData = fileContent; _cachedImageData = fileContent;
_isLoading = false; _isLoading = false;
_hasShownContent = true;
}); });
if (!widget.disableAnimation) {
_animationController.forward();
}
} }
} catch (e) { } catch (e) {
final error = 'Failed to load image: ${e.toString()}';
_globalErrorStates[widget.attachmentId] = error;
_globalLoadingStates[widget.attachmentId] = false;
if (mounted) { if (mounted) {
setState(() { setState(() {
_errorMessage = 'Failed to load image: ${e.toString()}'; _errorMessage = error;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -165,6 +235,25 @@ class _EnhancedImageAttachmentState
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin 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: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: _buildContent(),
);
}
Widget _buildContent() {
if (_isLoading) { if (_isLoading) {
return _buildLoadingState(); return _buildLoadingState();
} }
@@ -178,22 +267,36 @@ class _EnhancedImageAttachmentState
} }
// Handle different image data formats // Handle different image data formats
Widget imageWidget;
if (_cachedImageData!.startsWith('http')) { if (_cachedImageData!.startsWith('http')) {
return _buildNetworkImage(); imageWidget = _buildNetworkImage();
} else { } 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() { Widget _buildLoadingState() {
final constraints = widget.constraints ??
const BoxConstraints(
maxWidth: 300,
maxHeight: 300,
minHeight: 150,
minWidth: 200,
);
return Container( return Container(
constraints: widget.constraints ?? key: const ValueKey('loading'),
const BoxConstraints( constraints: constraints,
maxWidth: 300,
maxHeight: 300,
minHeight: 150,
minWidth: 200,
),
margin: const EdgeInsets.only(bottom: Spacing.xs), margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.5), color: context.conduitTheme.surfaceBackground.withValues(alpha: 0.5),
@@ -203,17 +306,42 @@ class _EnhancedImageAttachmentState
width: BorderWidth.thin, width: BorderWidth.thin,
), ),
), ),
child: Center( child: Stack(
child: CircularProgressIndicator( alignment: Alignment.center,
color: context.conduitTheme.buttonPrimary, children: [
strokeWidth: 2, // 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() { Widget _buildErrorState() {
return Container( return Container(
key: const ValueKey('error'),
constraints: widget.constraints ?? constraints: widget.constraints ??
const BoxConstraints( const BoxConstraints(
maxWidth: 300, maxWidth: 300,
@@ -254,7 +382,9 @@ class _EnhancedImageAttachmentState
), ),
], ],
), ),
); )
.animate()
.fadeIn(duration: const Duration(milliseconds: 200));
} }
Widget _buildNetworkImage() { Widget _buildNetworkImage() {
@@ -277,10 +407,19 @@ class _EnhancedImageAttachmentState
} }
final imageWidget = CachedNetworkImage( final imageWidget = CachedNetworkImage(
key: ValueKey('image_${widget.attachmentId}'),
imageUrl: _cachedImageData!, imageUrl: _cachedImageData!,
fit: BoxFit.cover, fit: BoxFit.cover,
httpHeaders: headers.isNotEmpty ? headers : null, 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) { errorWidget: (context, url, error) {
_errorMessage = error.toString(); _errorMessage = error.toString();
return _buildErrorState(); return _buildErrorState();
@@ -307,8 +446,10 @@ class _EnhancedImageAttachmentState
final imageBytes = base64.decode(actualBase64); final imageBytes = base64.decode(actualBase64);
final imageWidget = Image.memory( final imageWidget = Image.memory(
key: ValueKey('image_${widget.attachmentId}'),
imageBytes, imageBytes,
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, // Prevents flashing during rebuilds
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
_errorMessage = 'Failed to decode image'; _errorMessage = 'Failed to decode image';
return _buildErrorState(); return _buildErrorState();
@@ -323,7 +464,7 @@ class _EnhancedImageAttachmentState
} }
Widget _wrapImage(Widget imageWidget) { Widget _wrapImage(Widget imageWidget) {
return Container( final wrappedImage = Container(
constraints: widget.constraints ?? constraints: widget.constraints ??
const BoxConstraints( const BoxConstraints(
maxWidth: 400, maxWidth: 400,
@@ -332,17 +473,43 @@ class _EnhancedImageAttachmentState
margin: widget.isMarkdownFormat margin: widget.isMarkdownFormat
? const EdgeInsets.symmetric(vertical: Spacing.sm) ? const EdgeInsets.symmetric(vertical: Spacing.sm)
: EdgeInsets.zero, : EdgeInsets.zero,
child: Material( decoration: BoxDecoration(
color: Colors.transparent, borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: InkWell( // Add subtle shadow for depth
onTap: widget.onTap ?? () => _showFullScreenImage(context), boxShadow: [
child: Hero( BoxShadow(
tag: 'image_${widget.attachmentId}', color: context.conduitTheme.cardShadow.withValues(alpha: 0.1),
child: imageWidget, 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) { void _showFullScreenImage(BuildContext context) {

View File

@@ -62,18 +62,40 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
final imageCount = widget.message.attachmentIds!.length; 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) { if (imageCount == 1) {
// Single image - larger display // Single image - larger display
return Row( return Row(
key: ValueKey('user_single_${widget.message.attachmentIds![0]}'),
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
ClipRRect( Container(
borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble), decoration: BoxDecoration(
child: EnhancedImageAttachment( borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble),
attachmentId: widget.message.attachmentIds![0], boxShadow: [
isUserMessage: true, BoxShadow(
constraints: const BoxConstraints(maxWidth: 280, maxHeight: 350), 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<UserMessageBubble>
} else if (imageCount == 2) { } else if (imageCount == 2) {
// Two images side by side // Two images side by side
return Row( return Row(
key: ValueKey('user_double_${widget.message.attachmentIds!.join('_')}'),
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Flexible( Flexible(
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, 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( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(left: index == 0 ? 0 : Spacing.xs),
left: attachmentId == widget.message.attachmentIds!.first child: Container(
? 0 decoration: BoxDecoration(
: Spacing.xs, borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble),
), boxShadow: [
child: ClipRRect( BoxShadow(
borderRadius: BorderRadius.circular( color: context.conduitTheme.cardShadow.withValues(alpha: 0.08),
AppBorderRadius.messageBubble, blurRadius: 4,
offset: const Offset(0, 1),
),
],
), ),
child: EnhancedImageAttachment( child: ClipRRect(
attachmentId: attachmentId, borderRadius: BorderRadius.circular(AppBorderRadius.messageBubble),
isUserMessage: true, child: EnhancedImageAttachment(
constraints: const BoxConstraints( key: ValueKey('user_attachment_$attachmentId'),
maxWidth: 135, attachmentId: attachmentId,
maxHeight: 180, isUserMessage: true,
constraints: const BoxConstraints(
maxWidth: 135,
maxHeight: 180,
),
disableAnimation: widget.isStreaming,
), ),
), ),
), ),
@@ -116,6 +149,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
} else { } else {
// Grid layout for 3+ images // Grid layout for 3+ images
return Row( return Row(
key: ValueKey('user_grid_${widget.message.attachmentIds!.join('_')}'),
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Flexible( Flexible(
@@ -126,14 +160,28 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
spacing: Spacing.xs, spacing: Spacing.xs,
runSpacing: Spacing.xs, runSpacing: Spacing.xs,
children: widget.message.attachmentIds!.map((attachmentId) { children: widget.message.attachmentIds!.map((attachmentId) {
return ClipRRect( return Container(
borderRadius: BorderRadius.circular(AppBorderRadius.md), decoration: BoxDecoration(
child: EnhancedImageAttachment( borderRadius: BorderRadius.circular(AppBorderRadius.md),
attachmentId: attachmentId, boxShadow: [
isUserMessage: true, BoxShadow(
constraints: BoxConstraints( color: context.conduitTheme.cardShadow.withValues(alpha: 0.06),
maxWidth: imageCount == 3 ? 135 : 90, blurRadius: 3,
maxHeight: imageCount == 3 ? 135 : 90, 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,
), ),
), ),
); );