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 '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<ChatPage> {
// 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,35 +1367,76 @@ class _ChatPageState extends ConsumerState<ChatPage> {
],
),
// Floating Scroll to Bottom Button (only if there are messages)
if (_showScrollToBottom &&
ref.watch(chatMessagesProvider).isNotEmpty)
// Floating Scroll to Bottom Button with smooth appear/disappear
Positioned(
bottom:
Spacing.xxl +
Spacing
.xxxl, // Position higher to avoid overlapping chat input
bottom: Spacing.xxl + Spacing.xxxl,
right: Spacing.lg,
child: FloatingActionButton(
child: AnimatedSwitcher(
duration: AnimationDuration.microInteraction,
switchInCurve: AnimationCurves.microInteraction,
switchOutCurve: AnimationCurves.microInteraction,
transitionBuilder: (child, animation) {
final slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.15),
end: Offset.zero,
).animate(animation);
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: slideAnimation,
child: child,
),
);
},
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,
backgroundColor: context.conduitTheme.buttonPrimary,
foregroundColor: context.conduitTheme.buttonPrimaryText,
elevation: Elevation.medium,
child: Icon(
splashRadius: 24,
icon: Icon(
Platform.isIOS
? CupertinoIcons.arrow_down
: Icons.keyboard_arrow_down,
size: IconSize.large,
size: IconSize.lg,
color: context.conduitTheme.iconPrimary
.withValues(alpha: 0.9),
),
),
),
),
),
)
.animate()
.fadeIn(duration: AnimationDuration.microInteraction)
.slideY(
begin: AnimationValues.slideInFromBottom.dy,
end: AnimationValues.slideCenter.dy,
duration: AnimationDuration.microInteraction,
curve: AnimationCurves.microInteraction,
: const SizedBox.shrink(
key: ValueKey('scroll_to_bottom_hidden'),
),
),
),
// Edge overlay removed; rely on native interactive drawer drag
],

View File

@@ -274,7 +274,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
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<ModernChatInput>
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<ModernChatInput>
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

View File

@@ -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<Color>(
theme.loadingIndicator,
),
),
)
else if (onMorePressed != null)