From 0a09372c4a05e43b16b9f3ccebd6456897485e97 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:16:58 +0530 Subject: [PATCH] refactor: enhance scroll-to-bottom button functionality and improve chat input layout --- lib/features/chat/views/chat_page.dart | 121 +++++++++++++----- .../chat/widgets/modern_chat_input.dart | 17 ++- .../navigation/widgets/chats_drawer.dart | 15 +-- 3 files changed, 110 insertions(+), 43 deletions(-) diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 44280b1..d52e83b 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:io' show Platform, File; import 'dart:async'; +import 'dart:ui' show ImageFilter; import '../../../core/providers/app_providers.dart'; import '../providers/chat_providers.dart'; import '../../../core/utils/debug_logger.dart'; @@ -459,14 +460,29 @@ class _ChatPageState extends ConsumerState { // Debounce scroll handling to reduce rebuilds if (_scrollDebounceTimer?.isActive == true) return; - _scrollDebounceTimer = Timer(const Duration(milliseconds: 50), () { + _scrollDebounceTimer = Timer(const Duration(milliseconds: 80), () { if (!mounted || !_scrollController.hasClients) return; final maxScroll = _scrollController.position.maxScrollExtent; final currentScroll = _scrollController.position.pixels; - // Only show button if user has scrolled up significantly - final showButton = maxScroll > 100 && currentScroll < maxScroll - 200; + // Hysteresis thresholds to avoid flicker + const double showThreshold = + 300.0; // show when farther than this from bottom + const double hideThreshold = + 150.0; // hide when within this distance of bottom + + final bool farFromBottom = currentScroll < (maxScroll - showThreshold); + final bool nearBottom = currentScroll >= (maxScroll - hideThreshold); + + bool showButton; + if (_showScrollToBottom) { + // Currently shown: keep it until we are near the bottom + showButton = !nearBottom && maxScroll > showThreshold; + } else { + // Currently hidden: only show when far from bottom + showButton = farFromBottom && maxScroll > showThreshold; + } if (showButton != _showScrollToBottom && mounted) { setState(() { @@ -1351,36 +1367,77 @@ class _ChatPageState extends ConsumerState { ], ), - // Floating Scroll to Bottom Button (only if there are messages) - if (_showScrollToBottom && - ref.watch(chatMessagesProvider).isNotEmpty) - Positioned( - bottom: - Spacing.xxl + - Spacing - .xxxl, // Position higher to avoid overlapping chat input - right: Spacing.lg, - child: FloatingActionButton( - onPressed: _scrollToBottom, - backgroundColor: context.conduitTheme.buttonPrimary, - foregroundColor: context.conduitTheme.buttonPrimaryText, - elevation: Elevation.medium, - child: Icon( - Platform.isIOS - ? CupertinoIcons.arrow_down - : Icons.keyboard_arrow_down, - size: IconSize.large, - ), + // Floating Scroll to Bottom Button with smooth appear/disappear + Positioned( + bottom: Spacing.xxl + Spacing.xxxl, + right: Spacing.lg, + child: AnimatedSwitcher( + duration: AnimationDuration.microInteraction, + switchInCurve: AnimationCurves.microInteraction, + switchOutCurve: AnimationCurves.microInteraction, + transitionBuilder: (child, animation) { + final slideAnimation = Tween( + begin: const Offset(0, 0.15), + end: Offset.zero, + ).animate(animation); + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: slideAnimation, + child: child, ), - ) - .animate() - .fadeIn(duration: AnimationDuration.microInteraction) - .slideY( - begin: AnimationValues.slideInFromBottom.dy, - end: AnimationValues.slideCenter.dy, - duration: AnimationDuration.microInteraction, - curve: AnimationCurves.microInteraction, - ), + ); + }, + child: + (_showScrollToBottom && + ref.watch(chatMessagesProvider).isNotEmpty) + ? ClipRRect( + key: const ValueKey('scroll_to_bottom_visible'), + borderRadius: BorderRadius.circular( + AppBorderRadius.floatingButton, + ), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + decoration: BoxDecoration( + color: context + .conduitTheme + .surfaceContainerHighest + .withValues(alpha: 0.75), + border: Border.all( + color: context.conduitTheme.cardBorder + .withValues(alpha: 0.3), + width: BorderWidth.regular, + ), + borderRadius: BorderRadius.circular( + AppBorderRadius.floatingButton, + ), + boxShadow: ConduitShadows.button, + ), + child: SizedBox( + width: TouchTarget.button, + height: TouchTarget.button, + child: IconButton( + onPressed: _scrollToBottom, + splashRadius: 24, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.arrow_down + : Icons.keyboard_arrow_down, + size: IconSize.lg, + color: context.conduitTheme.iconPrimary + .withValues(alpha: 0.9), + ), + ), + ), + ), + ), + ) + : const SizedBox.shrink( + key: ValueKey('scroll_to_bottom_hidden'), + ), + ), + ), // Edge overlay removed; rely on native interactive drawer drag ], ), diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index da65542..c4d799c 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -274,7 +274,12 @@ class _ModernChatInputState extends ConsumerState children: [ // Collapsed/Expanded top row: text input with left/right buttons in collapsed Padding( - padding: const EdgeInsets.all(Spacing.inputPadding), + padding: const EdgeInsets.only( + left: Spacing.inputPadding, + right: Spacing.inputPadding, + top: Spacing.inputPadding, + bottom: Spacing.inputPadding, + ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -288,9 +293,15 @@ class _ModernChatInputState extends ConsumerState context, )!.addAttachment, showBackground: false, - iconSize: IconSize.large, + iconSize: IconSize.large + 2.0, ), const SizedBox(width: Spacing.xs), + ] else ...[ + // When expanded, the left padding was reduced to move the plus button. + // Add back spacing so the text field aligns comfortably from the edge. + SizedBox( + width: Spacing.inputPadding - Spacing.xs, + ), ], // Text input expands to fill Expanded( @@ -398,7 +409,7 @@ class _ModernChatInputState extends ConsumerState context, )!.addAttachment, showBackground: false, - iconSize: IconSize.large, + iconSize: IconSize.large + 2.0, ), const SizedBox(width: Spacing.xs), // Quick pills: no scroll, clip text within fixed max width diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 7c57300..45f4184 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -15,7 +15,6 @@ import '../../../shared/utils/ui_utils.dart'; import '../../../core/auth/auth_state_manager.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/models/user.dart' as models; -import '../../../shared/widgets/skeleton_loader.dart'; class ChatsDrawer extends ConsumerStatefulWidget { const ChatsDrawer({super.key}); @@ -1559,13 +1558,13 @@ class _ConversationTile extends StatelessWidget { const SizedBox(width: Spacing.xs), if (isLoading) SizedBox( - width: 72, - height: TouchTarget.small, - child: SkeletonLoader( - width: 72, - height: TouchTarget.small, - borderRadius: BorderRadius.circular(AppBorderRadius.chip), - isCompact: true, + width: IconSize.sm, + height: IconSize.sm, + child: CircularProgressIndicator( + strokeWidth: BorderWidth.medium, + valueColor: AlwaysStoppedAnimation( + theme.loadingIndicator, + ), ), ) else if (onMorePressed != null)