refactor: enhance scroll-to-bottom button functionality and improve chat input layout

This commit is contained in:
cogwheel0
2025-08-25 16:16:58 +05:30
parent f934c59d19
commit 0a09372c4a
3 changed files with 110 additions and 43 deletions

View File

@@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'dart:io' show Platform, File; import 'dart:io' show Platform, File;
import 'dart:async'; import 'dart:async';
import 'dart:ui' show ImageFilter;
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../providers/chat_providers.dart'; import '../providers/chat_providers.dart';
import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/debug_logger.dart';
@@ -459,14 +460,29 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Debounce scroll handling to reduce rebuilds // Debounce scroll handling to reduce rebuilds
if (_scrollDebounceTimer?.isActive == true) return; if (_scrollDebounceTimer?.isActive == true) return;
_scrollDebounceTimer = Timer(const Duration(milliseconds: 50), () { _scrollDebounceTimer = Timer(const Duration(milliseconds: 80), () {
if (!mounted || !_scrollController.hasClients) return; if (!mounted || !_scrollController.hasClients) return;
final maxScroll = _scrollController.position.maxScrollExtent; final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels; final currentScroll = _scrollController.position.pixels;
// Only show button if user has scrolled up significantly // Hysteresis thresholds to avoid flicker
final showButton = maxScroll > 100 && currentScroll < maxScroll - 200; 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) { if (showButton != _showScrollToBottom && mounted) {
setState(() { setState(() {
@@ -1351,36 +1367,77 @@ class _ChatPageState extends ConsumerState<ChatPage> {
], ],
), ),
// Floating Scroll to Bottom Button (only if there are messages) // Floating Scroll to Bottom Button with smooth appear/disappear
if (_showScrollToBottom && Positioned(
ref.watch(chatMessagesProvider).isNotEmpty) bottom: Spacing.xxl + Spacing.xxxl,
Positioned( right: Spacing.lg,
bottom: child: AnimatedSwitcher(
Spacing.xxl + duration: AnimationDuration.microInteraction,
Spacing switchInCurve: AnimationCurves.microInteraction,
.xxxl, // Position higher to avoid overlapping chat input switchOutCurve: AnimationCurves.microInteraction,
right: Spacing.lg, transitionBuilder: (child, animation) {
child: FloatingActionButton( final slideAnimation = Tween<Offset>(
onPressed: _scrollToBottom, begin: const Offset(0, 0.15),
backgroundColor: context.conduitTheme.buttonPrimary, end: Offset.zero,
foregroundColor: context.conduitTheme.buttonPrimaryText, ).animate(animation);
elevation: Elevation.medium, return FadeTransition(
child: Icon( opacity: animation,
Platform.isIOS child: SlideTransition(
? CupertinoIcons.arrow_down position: slideAnimation,
: Icons.keyboard_arrow_down, child: child,
size: IconSize.large,
),
), ),
) );
.animate() },
.fadeIn(duration: AnimationDuration.microInteraction) child:
.slideY( (_showScrollToBottom &&
begin: AnimationValues.slideInFromBottom.dy, ref.watch(chatMessagesProvider).isNotEmpty)
end: AnimationValues.slideCenter.dy, ? ClipRRect(
duration: AnimationDuration.microInteraction, key: const ValueKey('scroll_to_bottom_visible'),
curve: AnimationCurves.microInteraction, 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 // Edge overlay removed; rely on native interactive drawer drag
], ],
), ),

View File

@@ -274,7 +274,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
children: [ children: [
// Collapsed/Expanded top row: text input with left/right buttons in collapsed // Collapsed/Expanded top row: text input with left/right buttons in collapsed
Padding( 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( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@@ -288,9 +293,15 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
context, context,
)!.addAttachment, )!.addAttachment,
showBackground: false, showBackground: false,
iconSize: IconSize.large, iconSize: IconSize.large + 2.0,
), ),
const SizedBox(width: Spacing.xs), 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 // Text input expands to fill
Expanded( Expanded(
@@ -398,7 +409,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
context, context,
)!.addAttachment, )!.addAttachment,
showBackground: false, showBackground: false,
iconSize: IconSize.large, iconSize: IconSize.large + 2.0,
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
// Quick pills: no scroll, clip text within fixed max width // Quick pills: no scroll, clip text within fixed max width

View File

@@ -15,7 +15,6 @@ import '../../../shared/utils/ui_utils.dart';
import '../../../core/auth/auth_state_manager.dart'; import '../../../core/auth/auth_state_manager.dart';
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
import '../../../core/models/user.dart' as models; import '../../../core/models/user.dart' as models;
import '../../../shared/widgets/skeleton_loader.dart';
class ChatsDrawer extends ConsumerStatefulWidget { class ChatsDrawer extends ConsumerStatefulWidget {
const ChatsDrawer({super.key}); const ChatsDrawer({super.key});
@@ -1559,13 +1558,13 @@ class _ConversationTile extends StatelessWidget {
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
if (isLoading) if (isLoading)
SizedBox( SizedBox(
width: 72, width: IconSize.sm,
height: TouchTarget.small, height: IconSize.sm,
child: SkeletonLoader( child: CircularProgressIndicator(
width: 72, strokeWidth: BorderWidth.medium,
height: TouchTarget.small, valueColor: AlwaysStoppedAnimation<Color>(
borderRadius: BorderRadius.circular(AppBorderRadius.chip), theme.loadingIndicator,
isCompact: true, ),
), ),
) )
else if (onMorePressed != null) else if (onMorePressed != null)