From fcb89e56c76366677dc4829a11eb13452d3f17ad Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Fri, 10 Oct 2025 18:49:35 +0530 Subject: [PATCH] refactor: replace ListView with CustomScrollView and SliverList in chat page for improved performance - Updated the loading messages list to use CustomScrollView and SliverList, enhancing the layout and performance during message loading. - Refactored the actual messages display to utilize OptimizedSliverList, allowing for better lazy loading and smoother scrolling. - Adjusted padding and cache extent settings to optimize the user experience while navigating through messages. - Streamlined the message rendering logic to improve maintainability and responsiveness of the chat interface. --- lib/features/chat/views/chat_page.dart | 336 +++++++++++++------------ lib/shared/widgets/optimized_list.dart | 263 +------------------ 2 files changed, 187 insertions(+), 412 deletions(-) diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index bfe0494..9ec605c 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -687,82 +687,95 @@ class _ChatPageState extends ConsumerState { } Widget _buildLoadingMessagesList() { - return ListView.builder( + // Use slivers to align with the actual messages view. + // Do not attach the primary scroll controller here to avoid + // AnimatedSwitcher attaching the same controller twice. + return CustomScrollView( key: const ValueKey('loading_messages'), - // Do not reuse the primary scroll controller here to avoid - // attaching the same controller to multiple lists during - // AnimatedSwitcher transitions. controller: null, - padding: const EdgeInsets.fromLTRB( - Spacing.lg, - Spacing.md, - Spacing.lg, - Spacing.lg, - ), - physics: - const AlwaysScrollableScrollPhysics(), // Allow pull-to-refresh while loading - // Modest cache extent to avoid offscreen overwork but keep shimmer smooth + physics: const AlwaysScrollableScrollPhysics(), cacheExtent: 300, - 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, - ), - border: Border.all( - color: context.conduitTheme.cardBorder, - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.messageBubble(context), - ), - 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: index % 2 == 0 ? 180 : 120, - decoration: BoxDecoration( - color: context.conduitTheme.shimmerBase, - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - ), - ).animate().shimmer(duration: AnimationDuration.slow), - ], - ], - ), + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB( + Spacing.lg, + Spacing.md, + Spacing.lg, + Spacing.lg, ), - ); - }, + sliver: SliverList( + delegate: SliverChildBuilderDelegate((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, + ), + border: Border.all( + color: context.conduitTheme.cardBorder, + width: BorderWidth.regular, + ), + boxShadow: ConduitShadows.messageBubble(context), + ), + 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: index % 2 == 0 ? 180 : 120, + decoration: BoxDecoration( + color: context.conduitTheme.shimmerBase, + borderRadius: BorderRadius.circular( + AppBorderRadius.xs, + ), + ), + ).animate().shimmer(duration: AnimationDuration.slow), + ], + ], + ), + ), + ); + }, childCount: 6), + ), + ), + ], ); } @@ -805,102 +818,109 @@ class _ChatPageState extends ConsumerState { }); } - return OptimizedList( + return CustomScrollView( key: const ValueKey('actual_messages'), - scrollController: _scrollController, + controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), - items: messages, - padding: const EdgeInsets.fromLTRB( - Spacing.lg, - Spacing.md, - Spacing.lg, - Spacing.lg, - ), - itemBuilder: (context, message, index) { - final isUser = message.role == 'user'; - final isStreaming = message.isStreaming; + cacheExtent: 600, + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB( + Spacing.lg, + Spacing.md, + Spacing.lg, + Spacing.lg, + ), + sliver: OptimizedSliverList( + items: messages, + itemBuilder: (context, message, index) { + final isUser = message.role == 'user'; + final isStreaming = message.isStreaming; - final isSelected = _selectedMessageIds.contains(message.id); + final isSelected = _selectedMessageIds.contains(message.id); - // Resolve a friendly model display name for message headers - String? displayModelName; - Model? matchedModel; - final rawModel = message.model; - if (rawModel != null && rawModel.isNotEmpty) { - final modelsAsync = ref.watch(modelsProvider); - if (modelsAsync.hasValue) { - final models = modelsAsync.value!; - try { - // Prefer exact ID match; fall back to exact name match - final match = models.firstWhere( - (m) => m.id == rawModel || m.name == rawModel, + // Resolve a friendly model display name for message headers + String? displayModelName; + Model? matchedModel; + final rawModel = message.model; + if (rawModel != null && rawModel.isNotEmpty) { + final modelsAsync = ref.watch(modelsProvider); + if (modelsAsync.hasValue) { + final models = modelsAsync.value!; + try { + // Prefer exact ID match; fall back to exact name match + final match = models.firstWhere( + (m) => m.id == rawModel || m.name == rawModel, + ); + matchedModel = match; + displayModelName = _formatModelDisplayName(match.name); + } catch (_) { + // As a fallback, format the raw value to be more readable + displayModelName = _formatModelDisplayName(rawModel); + } + } else { + // Models not loaded yet; format raw value for readability + displayModelName = _formatModelDisplayName(rawModel); + } + } + + final modelIconUrl = resolveModelIconUrlForModel( + apiService, + matchedModel, ); - matchedModel = match; - displayModelName = _formatModelDisplayName(match.name); - } catch (_) { - // As a fallback, format the raw value to be more readable - displayModelName = _formatModelDisplayName(rawModel); - } - } else { - // Models not loaded yet; format raw value for readability - displayModelName = _formatModelDisplayName(rawModel); - } - } - final modelIconUrl = resolveModelIconUrlForModel( - apiService, - matchedModel, - ); + // Wrap message in selection container if in selection mode + Widget messageWidget; - // Wrap message in selection container if in selection mode - Widget messageWidget; + // Use documentation style for assistant messages, bubble for user messages + if (isUser) { + messageWidget = UserMessageBubble( + key: ValueKey('user-${message.id}'), + message: message, + isUser: isUser, + isStreaming: isStreaming, + modelName: displayModelName, + onCopy: () => _copyMessage(message.content), + onRegenerate: () => _regenerateMessage(message), + ); + } else { + messageWidget = assistant.AssistantMessageWidget( + key: ValueKey('assistant-${message.id}'), + message: message, + isStreaming: isStreaming, + modelName: displayModelName, + modelIconUrl: modelIconUrl, + onCopy: () => _copyMessage(message.content), + onRegenerate: () => _regenerateMessage(message), + ); + } - // Use documentation style for assistant messages, bubble for user messages - if (isUser) { - messageWidget = UserMessageBubble( - key: ValueKey('user-${message.id}'), - message: message, - isUser: isUser, - isStreaming: isStreaming, - modelName: displayModelName, - onCopy: () => _copyMessage(message.content), - onRegenerate: () => _regenerateMessage(message), - ); - } else { - messageWidget = assistant.AssistantMessageWidget( - key: ValueKey('assistant-${message.id}'), - message: message, - isStreaming: isStreaming, - modelName: displayModelName, - modelIconUrl: modelIconUrl, - onCopy: () => _copyMessage(message.content), - onRegenerate: () => _regenerateMessage(message), - ); - } - - // Add selection functionality if in selection mode - if (_isSelectionMode) { - return _SelectableMessageWrapper( - isSelected: isSelected, - onTap: () => _toggleMessageSelection(message.id), - onLongPress: () { - if (!_isSelectionMode) { - _toggleSelectionMode(); - _toggleMessageSelection(message.id); + // Add selection functionality if in selection mode + if (_isSelectionMode) { + return _SelectableMessageWrapper( + isSelected: isSelected, + onTap: () => _toggleMessageSelection(message.id), + onLongPress: () { + if (!_isSelectionMode) { + _toggleSelectionMode(); + _toggleMessageSelection(message.id); + } + }, + child: messageWidget, + ); + } else { + return GestureDetector( + onLongPress: () { + _toggleSelectionMode(); + _toggleMessageSelection(message.id); + }, + child: messageWidget, + ); } }, - child: messageWidget, - ); - } else { - return GestureDetector( - onLongPress: () { - _toggleSelectionMode(); - _toggleMessageSelection(message.id); - }, - child: messageWidget, - ); - } - }, + ), + ), + ], ); } diff --git a/lib/shared/widgets/optimized_list.dart b/lib/shared/widgets/optimized_list.dart index 8df72e7..6e64d03 100644 --- a/lib/shared/widgets/optimized_list.dart +++ b/lib/shared/widgets/optimized_list.dart @@ -4,249 +4,7 @@ import 'skeleton_loader.dart'; import 'package:conduit/l10n/app_localizations.dart'; import 'improved_loading_states.dart'; -/// Optimized list widget with virtualization and performance enhancements -class OptimizedList extends ConsumerStatefulWidget { - final List items; - final Widget Function(BuildContext context, T item, int index) itemBuilder; - final Widget? separatorBuilder; - final Widget? loadingWidget; - final Widget? emptyWidget; - final String? emptyMessage; - final Future Function()? onRefresh; - final VoidCallback? onLoadMore; - final bool hasMore; - final bool isLoading; - final EdgeInsetsGeometry? padding; - final ScrollController? scrollController; - final ScrollPhysics? physics; - final bool shrinkWrap; - final Axis scrollDirection; - final bool reverse; - final double? cacheExtent; - final int? itemExtent; - final bool addAutomaticKeepAlives; - final bool addRepaintBoundaries; - final bool enablePagination; - final double paginationThreshold; - final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; - - const OptimizedList({ - super.key, - required this.items, - required this.itemBuilder, - this.separatorBuilder, - this.loadingWidget, - this.emptyWidget, - this.emptyMessage, - this.onRefresh, - this.onLoadMore, - this.hasMore = false, - this.isLoading = false, - this.padding, - this.scrollController, - this.physics, - this.shrinkWrap = false, - this.scrollDirection = Axis.vertical, - this.reverse = false, - this.cacheExtent, - this.itemExtent, - this.addAutomaticKeepAlives = true, - this.addRepaintBoundaries = true, - this.enablePagination = false, - this.paginationThreshold = 0.8, - this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag, - }); - - @override - ConsumerState> createState() => _OptimizedListState(); -} - -class _OptimizedListState extends ConsumerState> { - late ScrollController _scrollController; - bool _isLoadingMore = false; - - @override - void initState() { - super.initState(); - _scrollController = widget.scrollController ?? ScrollController(); - - if (widget.enablePagination) { - _scrollController.addListener(_onScroll); - } - } - - @override - void dispose() { - if (widget.scrollController == null) { - _scrollController.dispose(); - } - super.dispose(); - } - - void _onScroll() { - if (!widget.enablePagination || - _isLoadingMore || - !widget.hasMore || - widget.onLoadMore == null) { - return; - } - - final maxScroll = _scrollController.position.maxScrollExtent; - final currentScroll = _scrollController.position.pixels; - final threshold = maxScroll * widget.paginationThreshold; - - if (currentScroll >= threshold) { - _loadMore(); - } - } - - Future _loadMore() async { - if (_isLoadingMore) return; - - setState(() { - _isLoadingMore = true; - }); - - try { - widget.onLoadMore?.call(); - } finally { - if (mounted) { - setState(() { - _isLoadingMore = false; - }); - } - } - } - - @override - Widget build(BuildContext context) { - // Show loading state - if (widget.isLoading && widget.items.isEmpty) { - return widget.loadingWidget ?? _buildDefaultLoadingWidget(); - } - - // Show empty state - if (widget.items.isEmpty) { - return widget.emptyWidget ?? - ImprovedEmptyState( - title: AppLocalizations.of(context)!.noItems, - subtitle: - widget.emptyMessage ?? - AppLocalizations.of(context)!.noItemsToDisplay, - icon: Icons.inbox_outlined, - ); - } - - // Build the list - Widget listWidget; - - final ScrollPhysics effectivePhysics = - widget.physics ?? - (widget.onRefresh != null - ? const AlwaysScrollableScrollPhysics() - : const ClampingScrollPhysics()); - - final reverse = widget.reverse; - - if (widget.separatorBuilder != null) { - listWidget = ListView.separated( - controller: _scrollController, - padding: widget.padding, - physics: effectivePhysics, - keyboardDismissBehavior: widget.keyboardDismissBehavior, - shrinkWrap: widget.shrinkWrap, - scrollDirection: widget.scrollDirection, - reverse: reverse, - cacheExtent: widget.cacheExtent ?? 250.0, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - addRepaintBoundaries: widget.addRepaintBoundaries, - itemCount: widget.items.length + (widget.hasMore ? 1 : 0), - separatorBuilder: (context, index) => widget.separatorBuilder!, - itemBuilder: (context, index) { - if (index >= widget.items.length) { - return _buildLoadMoreIndicator(); - } - - return _buildOptimizedItem(context, index, reverse: reverse); - }, - ); - } else { - listWidget = ListView.builder( - controller: _scrollController, - padding: widget.padding, - physics: effectivePhysics, - keyboardDismissBehavior: widget.keyboardDismissBehavior, - shrinkWrap: widget.shrinkWrap, - scrollDirection: widget.scrollDirection, - reverse: reverse, - cacheExtent: widget.cacheExtent ?? 250.0, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - addRepaintBoundaries: widget.addRepaintBoundaries, - itemCount: widget.items.length + (widget.hasMore ? 1 : 0), - itemExtent: widget.itemExtent?.toDouble(), - itemBuilder: (context, index) { - if (index >= widget.items.length) { - return _buildLoadMoreIndicator(); - } - - return _buildOptimizedItem(context, index, reverse: reverse); - }, - ); - } - - // Add refresh indicator if enabled - if (widget.onRefresh != null) { - return RefreshIndicator(onRefresh: widget.onRefresh!, child: listWidget); - } - - return listWidget; - } - - Widget _buildOptimizedItem( - BuildContext context, - int index, { - required bool reverse, - }) { - final effectiveIndex = reverse ? widget.items.length - index - 1 : index; - final item = widget.items[effectiveIndex]; - - if (widget.addRepaintBoundaries) { - return RepaintBoundary( - child: widget.itemBuilder(context, item, effectiveIndex), - ); - } - - return widget.itemBuilder(context, item, effectiveIndex); - } - - Widget _buildLoadMoreIndicator() { - return Container( - padding: const EdgeInsets.all(16.0), - alignment: Alignment.center, - child: _isLoadingMore - ? const CircularProgressIndicator() - : TextButton( - onPressed: _loadMore, - child: Text(AppLocalizations.of(context)!.loadMore), - ), - ); - } - - Widget _buildDefaultLoadingWidget() { - return ListView.builder( - padding: widget.padding, - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: 5, - itemBuilder: (context, index) => const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: SkeletonLoader(height: 80), - ), - ); - } -} - -/// Sliver version of OptimizedList for use in CustomScrollView +/// Sliver version of an optimized list for use in CustomScrollView. class OptimizedSliverList extends ConsumerWidget { final List items; final Widget Function(BuildContext context, T item, int index) itemBuilder; @@ -275,14 +33,14 @@ class OptimizedSliverList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Show loading state + // Loading state if (isLoading && items.isEmpty) { return SliverToBoxAdapter( child: loadingWidget ?? _buildDefaultLoadingWidget(), ); } - // Show empty state + // Empty state if (items.isEmpty) { return SliverToBoxAdapter( child: @@ -300,17 +58,16 @@ class OptimizedSliverList extends ConsumerWidget { ); } - // Build the list + // List content return SliverList( delegate: SliverChildBuilderDelegate( (context, index) { if (index >= items.length) { if (hasMore) { - // Trigger load more + // Trigger pagination once this placeholder is built WidgetsBinding.instance.addPostFrameCallback((_) { onLoadMore?.call(); }); - return Container( padding: const EdgeInsets.all(16.0), alignment: Alignment.center, @@ -323,11 +80,10 @@ class OptimizedSliverList extends ConsumerWidget { final item = items[index]; final widget = itemBuilder(context, item, index); - // Wrap in repaint boundary for performance + // Wrap in repaint boundary for perf if (addRepaintBoundaries) { return RepaintBoundary(child: widget); } - return widget; }, childCount: items.length + (hasMore ? 1 : 0), @@ -350,7 +106,7 @@ class OptimizedSliverList extends ConsumerWidget { } } -/// Animated list with optimizations +/// Animated list with lightweight add/remove animations. class OptimizedAnimatedList extends ConsumerStatefulWidget { final List items; final Widget Function( @@ -397,7 +153,7 @@ class _OptimizedAnimatedListState void didUpdateWidget(OptimizedAnimatedList oldWidget) { super.didUpdateWidget(oldWidget); - // Handle item additions + // Additions for (int i = 0; i < widget.items.length; i++) { if (i >= _items.length || widget.items[i] != _items[i]) { _items.insert(i, widget.items[i]); @@ -408,7 +164,7 @@ class _OptimizedAnimatedListState } } - // Handle item removals + // Removals for (int i = _items.length - 1; i >= widget.items.length; i--) { final removedItem = _items[i]; _items.removeAt(i); @@ -431,7 +187,6 @@ class _OptimizedAnimatedListState initialItemCount: _items.length, itemBuilder: (context, index, animation) { if (index >= _items.length) return const SizedBox.shrink(); - return widget.itemBuilder(context, _items[index], index, animation); }, );