From 6a6548de64b2fa09faa06cc229861a4342bbb34e Mon Sep 17 00:00:00 2001 From: Tristan Katana <50181095+felixmuinde@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:57:46 +0300 Subject: [PATCH] feat(mobile): Add animated TypingIndicator for AI chat responses (#1269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): Add animated TypingIndicator widget for AI chat responses Replaces the static CircularProgressIndicator + "AI is thinking..." text with an animated TypingIndicator showing pulsing dots while the AI generates a response. Respects the app color scheme so it works in light and dark themes. Co-Authored-By: Claude Sonnet 4.6 * Fix: Normalize stagger progress to [0,1) in TypingIndicator to prevent negative opacity Co-Authored-By: Claude Sonnet 4.6 * fix(mobile): fix typing indicator visibility and run pub get The typing indicator was only visible for the duration of the HTTP POST (~instant) because it was tied to `isSendingMessage`. It now tracks the full AI response lifecycle via a new `isWaitingForResponse` state that stays true through polling until the response stabilises. - Add `isWaitingForResponse` to ChatProvider; set on poll start, clear on poll stop with notifyListeners so the UI reacts correctly - Move TypingIndicator inside the ListView as an assistant bubble so it scrolls naturally with the conversation - Add provider listener that auto-scrolls on every update while waiting for a response - Redesign TypingIndicator: 3-dot sequential bounce animation (classic chat style) replacing the simultaneous fade * feat(mobile): overhaul new-chat flow and fix typing indicator bugs chat is created lazily on first send, eliminating all pre-conversation flashes and crashes - Inject user message locally into _currentChat immediately on createChat so it renders before the first poll completes - Hide thinking indicator the moment the first assistant content arrives (was waiting one extra 2s poll cycle before disappearing) - Fix double-spinner on new chat: remove manual showDialog spinner and use a local _isCreating flag on the FAB instead * fix(mboile) : address PR review — widget lifecycle safety and new-chat regression * Fic(mobile): Add mounted check in post-frame callback --------- Co-authored-by: Claude Sonnet 4.6 --- mobile/lib/providers/chat_provider.dart | 38 ++- .../lib/screens/chat_conversation_screen.dart | 305 +++++++++++------- mobile/lib/screens/chat_list_screen.dart | 58 +--- mobile/lib/widgets/typing_indicator.dart | 99 ++++++ 4 files changed, 335 insertions(+), 165 deletions(-) create mode 100644 mobile/lib/widgets/typing_indicator.dart diff --git a/mobile/lib/providers/chat_provider.dart b/mobile/lib/providers/chat_provider.dart index 5016c934f..158e2a147 100644 --- a/mobile/lib/providers/chat_provider.dart +++ b/mobile/lib/providers/chat_provider.dart @@ -11,6 +11,7 @@ class ChatProvider with ChangeNotifier { Chat? _currentChat; bool _isLoading = false; bool _isSendingMessage = false; + bool _isWaitingForResponse = false; String? _errorMessage; Timer? _pollingTimer; @@ -22,6 +23,7 @@ class ChatProvider with ChangeNotifier { Chat? get currentChat => _currentChat; bool get isLoading => _isLoading; bool get isSendingMessage => _isSendingMessage; + bool get isWaitingForResponse => _isWaitingForResponse; String? get errorMessage => _errorMessage; /// Fetch list of chats @@ -103,18 +105,31 @@ class ChatProvider with ChangeNotifier { if (result['success'] == true) { final chat = result['chat'] as Chat; - _currentChat = chat; - _chats.insert(0, chat); _errorMessage = null; - // Start polling for AI response if initial message was sent if (initialMessage != null) { + // Inject the user message locally so the UI renders it immediately + // without waiting for the first poll. + final now = DateTime.now(); + final userMessage = Message( + id: 'pending_${now.millisecondsSinceEpoch}', + type: 'text', + role: 'user', + content: initialMessage, + createdAt: now, + updatedAt: now, + ); + _currentChat = chat.copyWith(messages: [userMessage]); + _chats.insert(0, _currentChat!); _startPolling(accessToken, chat.id); + } else { + _currentChat = chat; + _chats.insert(0, chat); } _isLoading = false; notifyListeners(); - return chat; + return _currentChat!; } else { _errorMessage = result['error'] ?? 'Failed to create chat'; _isLoading = false; @@ -244,8 +259,10 @@ class ChatProvider with ChangeNotifier { /// Start polling for new messages (AI responses) void _startPolling(String accessToken, String chatId) { - _stopPolling(); + _pollingTimer?.cancel(); _lastAssistantContentLength = null; + _isWaitingForResponse = true; + notifyListeners(); _pollingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { await _pollForUpdates(accessToken, chatId); @@ -256,6 +273,7 @@ class ChatProvider with ChangeNotifier { void _stopPolling() { _pollingTimer?.cancel(); _pollingTimer = null; + _isWaitingForResponse = false; } /// Poll for updates @@ -302,6 +320,13 @@ class ChatProvider with ChangeNotifier { if (shouldUpdate) { _currentChat = updatedChat; + // Hide thinking indicator as soon as the first assistant content arrives. + if (_isWaitingForResponse) { + final lastMsg = updatedChat.messages.lastOrNull; + if (lastMsg != null && lastMsg.isAssistant && lastMsg.content.isNotEmpty) { + _isWaitingForResponse = false; + } + } notifyListeners(); } @@ -311,9 +336,10 @@ class ChatProvider with ChangeNotifier { if (newLen > (_lastAssistantContentLength ?? 0)) { _lastAssistantContentLength = newLen; } else { - // Content stable: no growth since last poll + // Content stable: no growth since last poll — done. _stopPolling(); _lastAssistantContentLength = null; + notifyListeners(); } } } diff --git a/mobile/lib/screens/chat_conversation_screen.dart b/mobile/lib/screens/chat_conversation_screen.dart index 66b6d20c4..7bc5358fb 100644 --- a/mobile/lib/screens/chat_conversation_screen.dart +++ b/mobile/lib/screens/chat_conversation_screen.dart @@ -5,13 +5,15 @@ import '../models/chat.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import '../models/message.dart'; +import '../widgets/typing_indicator.dart'; class _SendMessageIntent extends Intent { const _SendMessageIntent(); } class ChatConversationScreen extends StatefulWidget { - final String chatId; + /// Null means this is a brand-new chat — it will be created on first send. + final String? chatId; const ChatConversationScreen({ super.key, @@ -26,23 +28,78 @@ class _ChatConversationScreenState extends State { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); + /// Tracks the real chat ID once the chat has been created. + String? _chatId; + + ChatProvider? _chatProvider; + bool _listenerAdded = false; + @override void initState() { super.initState(); - _loadChat(); + _chatId = widget.chatId; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _chatProvider = Provider.of(context, listen: false); + _chatProvider!.addListener(_onChatChanged); + _listenerAdded = true; + if (_chatId == null) { + _chatProvider!.clearCurrentChat(); + } + }); + if (_chatId != null) { + _loadChat(); + } } @override void dispose() { + if (_listenerAdded && _chatProvider != null) { + _chatProvider!.removeListener(_onChatChanged); + _chatProvider = null; + _listenerAdded = false; + } _messageController.dispose(); _scrollController.dispose(); super.dispose(); } - Future _loadChat() async { + void _onChatChanged() { + if (!mounted) return; + final chatProvider = Provider.of(context, listen: false); + if (chatProvider.isWaitingForResponse || chatProvider.isSendingMessage) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _scrollToBottom(); + }); + } + } + + void _scrollToBottom() { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + Future _loadChat({bool forceRefresh = false}) async { + if (_chatId == null) return; + final authProvider = Provider.of(context, listen: false); final chatProvider = Provider.of(context, listen: false); + // Skip fetch if the provider already has this chat loaded (e.g. just created). + if (!forceRefresh && chatProvider.currentChat?.id == _chatId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + return; + } + final accessToken = await authProvider.getValidAccessToken(); if (accessToken == null) { await authProvider.logout(); @@ -51,12 +108,11 @@ class _ChatConversationScreenState extends State { await chatProvider.fetchChat( accessToken: accessToken, - chatId: widget.chatId, + chatId: _chatId!, ); - // Scroll to bottom after loading WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { + if (mounted && _scrollController.hasClients) { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } }); @@ -75,25 +131,47 @@ class _ChatConversationScreenState extends State { return; } - final shouldUpdateTitle = chatProvider.currentChat?.hasDefaultTitle == true; - _messageController.clear(); - final delivered = await chatProvider.sendMessage( - accessToken: accessToken, - chatId: widget.chatId, - content: content, - ); - - if (delivered && shouldUpdateTitle) { - await chatProvider.updateChatTitle( + if (_chatId == null) { + // First message in a new chat — create the chat with it. + final chat = await chatProvider.createChat( accessToken: accessToken, - chatId: widget.chatId, title: Chat.generateTitle(content), + initialMessage: content, ); + if (!mounted) return; + if (chat == null) { + // Restore the message so the user doesn't lose it. + _messageController.text = content; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(chatProvider.errorMessage ?? 'Failed to start conversation. Please try again.'), + backgroundColor: Colors.red, + ), + ); + return; + } + setState(() => _chatId = chat.id); + } else { + final shouldUpdateTitle = + chatProvider.currentChat?.hasDefaultTitle == true; + + final delivered = await chatProvider.sendMessage( + accessToken: accessToken, + chatId: _chatId!, + content: content, + ); + + if (delivered && shouldUpdateTitle) { + await chatProvider.updateChatTitle( + accessToken: accessToken, + chatId: _chatId!, + title: Chat.generateTitle(content), + ); + } } - // Scroll to bottom after sending WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( @@ -137,13 +215,17 @@ class _ChatConversationScreenState extends State { }, ); - if (newTitle != null && newTitle.isNotEmpty && newTitle != currentTitle && mounted) { + if (newTitle != null && + newTitle.isNotEmpty && + newTitle != currentTitle && + mounted) { + if (_chatId == null) return; final authProvider = Provider.of(context, listen: false); final accessToken = await authProvider.getValidAccessToken(); if (accessToken != null) { await chatProvider.updateChatTitle( accessToken: accessToken, - chatId: widget.chatId, + chatId: _chatId!, title: newTitle, ); } @@ -164,57 +246,56 @@ class _ChatConversationScreenState extends State { appBar: AppBar( title: Consumer( builder: (context, chatProvider, _) { + final title = chatProvider.currentChat?.title ?? 'New Conversation'; return GestureDetector( - onTap: _editTitle, + onTap: _chatId != null ? _editTitle : null, child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( - chatProvider.currentChat?.title ?? 'Chat', + title, overflow: TextOverflow.ellipsis, ), ), - const SizedBox(width: 4), - const Icon(Icons.edit, size: 18), + if (_chatId != null) ...[ + const SizedBox(width: 4), + const Icon(Icons.edit, size: 18), + ], ], ), ); }, ), actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _loadChat, - tooltip: 'Refresh', - ), + if (widget.chatId != null) + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => _loadChat(forceRefresh: true), + tooltip: 'Refresh', + ), ], ), body: Consumer( builder: (context, chatProvider, _) { if (chatProvider.isLoading && chatProvider.currentChat == null) { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } - if (chatProvider.errorMessage != null && chatProvider.currentChat == null) { + if (chatProvider.errorMessage != null && + chatProvider.currentChat == null && + _chatId != null) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.error_outline, - size: 64, - color: colorScheme.error, - ), + Icon(Icons.error_outline, + size: 64, color: colorScheme.error), const SizedBox(height: 16), - Text( - 'Failed to load chat', - style: Theme.of(context).textTheme.titleLarge, - ), + Text('Failed to load chat', + style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), Text( chatProvider.errorMessage!, @@ -237,68 +318,23 @@ class _ChatConversationScreenState extends State { return Column( children: [ - // Messages list Expanded( - child: messages.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.chat_bubble_outline, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - 'Start a conversation', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Send a message to begin chatting with the AI assistant.', - style: TextStyle(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - ], - ), - ) - : ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; - return _MessageBubble( - message: message, - formatTime: _formatTime, - ); - }, - ), - ), - - // Loading indicator when sending - if (chatProvider.isSendingMessage) - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 12), - Text( - 'AI is thinking...', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ), + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + itemCount: messages.length + + (chatProvider.isWaitingForResponse ? 1 : 0), + itemBuilder: (context, index) { + if (index == messages.length) { + return const _TypingIndicatorBubble(); + } + return _MessageBubble( + message: messages[index], + formatTime: _formatTime, + ); + }, ), + ), // Message input Container( @@ -315,7 +351,8 @@ class _ChatConversationScreenState extends State { ), child: Shortcuts( shortcuts: const { - SingleActivator(LogicalKeyboardKey.enter): _SendMessageIntent(), + SingleActivator(LogicalKeyboardKey.enter): + _SendMessageIntent(), }, child: Actions( actions: >{ @@ -343,12 +380,15 @@ class _ChatConversationScreenState extends State { ), maxLines: null, textCapitalization: TextCapitalization.sentences, + autofocus: _chatId == null, ), ), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.send), - onPressed: chatProvider.isSendingMessage ? null : _sendMessage, + onPressed: chatProvider.isSendingMessage + ? null + : _sendMessage, color: colorScheme.primary, iconSize: 28, ), @@ -382,7 +422,8 @@ class _MessageBubble extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( - mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!isUser) @@ -398,12 +439,16 @@ class _MessageBubble extends StatelessWidget { const SizedBox(width: 8), Flexible( child: Column( - crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: isUser ? colorScheme.primary : colorScheme.surfaceContainerHighest, + color: isUser + ? colorScheme.primary + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), ), child: Column( @@ -412,10 +457,13 @@ class _MessageBubble extends StatelessWidget { Text( message.content, style: TextStyle( - color: isUser ? colorScheme.onPrimary : colorScheme.onSurfaceVariant, + color: isUser + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, ), ), - if (message.toolCalls != null && message.toolCalls!.isNotEmpty) + if (message.toolCalls != null && + message.toolCalls!.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), child: Wrap( @@ -463,3 +511,40 @@ class _MessageBubble extends StatelessWidget { ); } } + +class _TypingIndicatorBubble extends StatelessWidget { + const _TypingIndicatorBubble(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: colorScheme.primaryContainer, + child: Icon( + Icons.smart_toy, + size: 18, + color: colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: const TypingIndicator(), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/chat_list_screen.dart b/mobile/lib/screens/chat_list_screen.dart index d49bbd3fd..3c1606ecd 100644 --- a/mobile/lib/screens/chat_list_screen.dart +++ b/mobile/lib/screens/chat_list_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../models/chat.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import 'chat_conversation_screen.dart'; @@ -36,56 +35,17 @@ class _ChatListScreenState extends State { await _loadChats(); } - Future _createNewChat() async { - final authProvider = Provider.of(context, listen: false); - final chatProvider = Provider.of(context, listen: false); + Future _openNewChat() async { + if (!mounted) return; - final accessToken = await authProvider.getValidAccessToken(); - if (accessToken == null) { - await authProvider.logout(); - return; - } - - // Show loading dialog - if (mounted) { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const Center( - child: CircularProgressIndicator(), - ), - ); - } - - final chat = await chatProvider.createChat( - accessToken: accessToken, - title: Chat.defaultTitle, + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ChatConversationScreen(chatId: null), + ), ); - // Close loading dialog - if (mounted) { - Navigator.pop(context); - } - - if (chat != null && mounted) { - // Navigate to chat conversation - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChatConversationScreen(chatId: chat.id), - ), - ); - - // Refresh list after returning - _loadChats(); - } else if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(chatProvider.errorMessage ?? 'Failed to create chat'), - backgroundColor: Colors.red, - ), - ); - } + if (mounted) _loadChats(); } String _formatDateTime(DateTime dateTime) { @@ -297,7 +257,7 @@ class _ChatListScreenState extends State { }, ), floatingActionButton: FloatingActionButton( - onPressed: _createNewChat, + onPressed: _openNewChat, tooltip: 'New Chat', child: const Icon(Icons.add), ), diff --git a/mobile/lib/widgets/typing_indicator.dart b/mobile/lib/widgets/typing_indicator.dart new file mode 100644 index 000000000..f23725b33 --- /dev/null +++ b/mobile/lib/widgets/typing_indicator.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +/// Animated 3-dot "Thinking..." indicator shown while the AI generates a response. +/// Each dot bounces up in sequence, giving the classic chat typing indicator feel. +class TypingIndicator extends StatefulWidget { + const TypingIndicator({super.key}); + + @override + State createState() => _TypingIndicatorState(); +} + +class _TypingIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final dotColor = colorScheme.onSurfaceVariant; + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Thinking', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(width: 6), + SizedBox( + height: 20, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(3, (index) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final offset = _dotOffset(index, _controller.value); + return Padding( + padding: EdgeInsets.only(right: index < 2 ? 5 : 0), + child: Transform.translate( + offset: Offset(0, offset), + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: dotColor.withValues(alpha: 0.75), + shape: BoxShape.circle, + ), + ), + ), + ); + }, + ); + }), + ), + ), + ], + ); + } + + /// Returns the vertical offset (px) for a dot at [index] given the + /// controller's current [value] in [0, 1). + /// Each dot is delayed by 1/3 of the cycle so they bounce in sequence. + double _dotOffset(int index, double value) { + const bounceHeight = 5.0; + const dotCount = 3; + final phase = (value - index / dotCount + 1.0) % 1.0; + + // Bounce occupies the first 40% of each dot's phase; rest is idle. + if (phase < 0.2) { + // Rising: 0 → peak + return -bounceHeight * (phase / 0.2); + } else if (phase < 0.4) { + // Falling: peak → 0 + return -bounceHeight * (1.0 - (phase - 0.2) / 0.2); + } + return 0.0; + } +}