refactor: enhance scroll-to-bottom button functionality and improve chat input layout
This commit is contained in:
@@ -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
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user