refactor: image attachments
This commit is contained in:
139
lib/features/chat/providers/attachment_cache_provider.dart
Normal file
139
lib/features/chat/providers/attachment_cache_provider.dart
Normal 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];
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user