import "package:provider/provider.dart"; import "package:shadcn_flutter/shadcn_flutter.dart"; import "../providers/chat_provider.dart"; import "message_bubble.dart"; class ChatView extends StatefulWidget { const ChatView(); @override State createState() => _ChatViewState(); } class _ChatViewState extends State { late ScrollController _scrollController; List _previousMessageContents = []; bool _isUserScrolling = false; DateTime? _lastScrollTime; bool _showJumpToBottom = false; bool _hasNewMessagesWhileScrolledAway = false; @override void initState() { super.initState(); _scrollController = ScrollController(); _scrollController.addListener(_handleScroll); } @override void dispose() { _scrollController.removeListener(_handleScroll); _scrollController.dispose(); super.dispose(); } void _handleScroll() { _lastScrollTime = DateTime.now(); _isUserScrolling = true; // Update whether to show jump-to-bottom button if (_scrollController.hasClients) { final position = _scrollController.position; final isFarFromBottom = position.pixels < position.maxScrollExtent - 200; if (isFarFromBottom != _showJumpToBottom) { setState(() { _showJumpToBottom = isFarFromBottom; }); } // If user scrolls to bottom manually, clear the new messages flag if (!isFarFromBottom) { setState(() { _hasNewMessagesWhileScrolledAway = false; }); } } // Check if scrolling has stopped (no scroll events for 150ms) Future.delayed(const Duration(milliseconds: 150), () { if (_lastScrollTime != null && DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) { if (mounted) { setState(() { _isUserScrolling = false; }); } } }); } bool _isNearBottom() { if (!_scrollController.hasClients) return false; final position = _scrollController.position; // Consider user to be "near bottom" if they're within 150 pixels of the bottom // Add a small buffer so we don't trigger on exact bottom return position.pixels >= position.maxScrollExtent - 150; } void _jumpToBottom() { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); setState(() { _showJumpToBottom = false; _hasNewMessagesWhileScrolledAway = false; }); } } @override Widget build(BuildContext context) { return Consumer( builder: (context, chatProvider, _) { // Get current messages final currentMessages = chatProvider.messages; // Check if messages have actually changed (not just re-renders) bool messagesChanged = false; if (currentMessages.length != _previousMessageContents.length) { messagesChanged = true; } else { for (int i = 0; i < currentMessages.length; i++) { if (i >= _previousMessageContents.length || currentMessages[i].content != _previousMessageContents[i]) { messagesChanged = true; break; } } } if (messagesChanged && currentMessages.isNotEmpty) { // Check if we're near the bottom final nearBottom = _isNearBottom(); if (nearBottom && !_isUserScrolling) { // Auto-scroll to bottom if user is near bottom and not scrolling WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); _hasNewMessagesWhileScrolledAway = false; } else if (!nearBottom) { // User is scrolled away from bottom when new messages arrive _hasNewMessagesWhileScrolledAway = true; } } // Update previous message state for next build WidgetsBinding.instance.addPostFrameCallback((_) { _previousMessageContents = currentMessages.map((m) => m.content).toList(); }); return Stack( children: [ ListView.builder( controller: _scrollController, itemCount: currentMessages.length, itemBuilder: (context, index) { final message = currentMessages[index]; return Padding( padding: EdgeInsetsGeometry.only( top: index != 0 ? 12 : 0 ), child: MessageBubble(message: message) ); }, ), if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway) Positioned( bottom: 16, right: 16, child: GestureDetector( onTap: _jumpToBottom, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.background.withOpacity(0.9), borderRadius: BorderRadius.circular(24), boxShadow: [ BoxShadow( color: const Color(0xFF000000).withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.arrowDown, size: 16, color: Theme.of(context).colorScheme.foreground, ), const SizedBox(width: 6), Text( "New messages", style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.foreground, ), ), ], ), ), ), ), ], ); }, ); } }