From 04b0122dbfe1f3a53a2d594ef2c6cf2f93601d1f Mon Sep 17 00:00:00 2001 From: Tristan the Katana <50181095+felixmuinde@users.noreply.github.com> Date: Tue, 12 May 2026 20:57:13 +0300 Subject: [PATCH] feat(mobile): add suggested questions to empty chat screen (#1773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): add suggested questions to empty chat screen - New constants file (lib/constants/suggested_questions.dart) for the 4 suggested question chips, kept separate from screen logic with a clear l10n upgrade path noted in comments - Empty chat screen now shows a personalised greeting and tappable OutlinedButton chips; tapping one pre-fills and sends the message - Optimistic message insertion in ChatProvider.sendMessage so the user message and typing indicator appear instantly on tap, with rollback on failure - Full AI response revealed only once polling detects stable content (2 consecutive polls with no growth), preventing partial responses from flashing on screen - fetchChat stops any in-progress polling before fetching so a manual refresh always shows the authoritative server response - Fixed updateChatTitle silently wiping messages when the title-update API response omits the messages array Co-Authored-By: Claude Sonnet 4.6 * fix(mobile): address PR review comments - Extract _rollbackOptimisticMessage helper to eliminate duplicated rollback logic in sendMessage failure and catch branches - Replace raw 'Error: ${e.toString()}' user-facing strings with a generic message; retain technical details via debugPrint in each catch block - Replace inline ternary in updateChatTitle with explicit if/else for readability while preserving message-preservation behaviour - Fix non-reactive AuthProvider read inside Consumer builder (listen: false → listen: true) so greeting updates when user's firstName changes Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- mobile/lib/constants/suggested_questions.dart | 14 +++ mobile/lib/providers/chat_provider.dart | 108 +++++++++++++---- .../lib/screens/chat_conversation_screen.dart | 112 +++++++++++++++--- 3 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 mobile/lib/constants/suggested_questions.dart diff --git a/mobile/lib/constants/suggested_questions.dart b/mobile/lib/constants/suggested_questions.dart new file mode 100644 index 000000000..818be7652 --- /dev/null +++ b/mobile/lib/constants/suggested_questions.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +/// Suggested questions shown on the empty chat screen. +/// +/// l10n upgrade path: when Flutter localisation is added to the mobile app, +/// replace this const list with a function that accepts [BuildContext] and +/// returns localised strings via AppLocalizations. The call site in +/// _EmptyState requires only a one-line change. +const List<({IconData icon, String text})> suggestedQuestions = [ + (icon: Icons.account_balance_wallet_outlined, text: 'What is my current net worth?'), + (icon: Icons.show_chart, text: 'How has my spending changed this month?'), + (icon: Icons.savings_outlined, text: 'How can I improve my savings rate?'), + (icon: Icons.receipt_long_outlined, text: 'What are my biggest expenses lately?'), +]; diff --git a/mobile/lib/providers/chat_provider.dart b/mobile/lib/providers/chat_provider.dart index d68caac28..317535fdb 100644 --- a/mobile/lib/providers/chat_provider.dart +++ b/mobile/lib/providers/chat_provider.dart @@ -23,6 +23,11 @@ class ChatProvider with ChangeNotifier { /// Used to detect when the LLM has finished writing (no growth between polls). int? _lastAssistantContentLength; + /// Number of consecutive polls with no content growth. + /// Requires 2 consecutive stable polls before declaring the response complete, + /// to avoid prematurely stopping on a brief server-side generation pause. + int _stablePollingCount = 0; + List get chats => _chats; Chat? get currentChat => _currentChat; bool get isLoading => _isLoading; @@ -55,7 +60,8 @@ class ChatProvider with ChangeNotifier { _errorMessage = result['error'] ?? 'Failed to fetch chats'; } } catch (e) { - _errorMessage = 'Error: ${e.toString()}'; + debugPrint('fetchChats error: $e'); + _errorMessage = 'Something went wrong. Please try again.'; } finally { _isLoading = false; notifyListeners(); @@ -67,6 +73,10 @@ class ChatProvider with ChangeNotifier { required String accessToken, required String chatId, }) async { + // Stop any in-progress polling — the server response is the source of truth + // when explicitly fetching a chat. This prevents a stale poll from + // overwriting the freshly fetched data and ensures the message filter lifts. + _stopPolling(); _isLoading = true; _errorMessage = null; notifyListeners(); @@ -84,7 +94,8 @@ class ChatProvider with ChangeNotifier { _errorMessage = result['error'] ?? 'Failed to fetch chat'; } } catch (e) { - _errorMessage = 'Error: ${e.toString()}'; + debugPrint('fetchChat error: $e'); + _errorMessage = 'Something went wrong. Please try again.'; } finally { _isLoading = false; notifyListeners(); @@ -142,13 +153,25 @@ class ChatProvider with ChangeNotifier { return null; } } catch (e) { - _errorMessage = 'Error: ${e.toString()}'; + debugPrint('createChat error: $e'); + _errorMessage = 'Something went wrong. Please try again.'; _isLoading = false; notifyListeners(); return null; } } + void _rollbackOptimisticMessage(String optimisticId, String chatId) { + if (_currentChat != null && _currentChat!.id == chatId) { + _currentChat = _currentChat!.copyWith( + messages: _currentChat!.messages + .where((m) => m.id != optimisticId) + .toList(), + ); + } + _isWaitingForResponse = false; + } + /// Send a message to the current chat. /// Returns true if delivery succeeded, false otherwise. Future sendMessage({ @@ -158,6 +181,26 @@ class ChatProvider with ChangeNotifier { }) async { _isSendingMessage = true; _errorMessage = null; + + // Optimistically add the user message so it appears immediately — before + // the network round-trip completes. This makes the empty-state disappear + // and the typing indicator show at the same instant. + final now = DateTime.now(); + final optimisticId = 'pending-${now.millisecondsSinceEpoch}'; + final optimisticMessage = Message( + id: optimisticId, + type: 'text', + role: 'user', + content: content, + createdAt: now, + updatedAt: now, + ); + if (_currentChat != null && _currentChat!.id == chatId) { + _currentChat = _currentChat!.copyWith( + messages: [..._currentChat!.messages, optimisticMessage], + ); + } + _isWaitingForResponse = true; notifyListeners(); try { @@ -170,11 +213,13 @@ class ChatProvider with ChangeNotifier { if (result['success'] == true) { final message = result['message'] as Message; - // Add the message to current chat if it's loaded + // Replace the optimistic message with the confirmed one from the server. if (_currentChat != null && _currentChat!.id == chatId) { - _currentChat = _currentChat!.copyWith( - messages: [..._currentChat!.messages, message], - ); + final updated = _currentChat!.messages + .where((m) => m.id != optimisticMessage.id) + .toList() + ..add(message); + _currentChat = _currentChat!.copyWith(messages: updated); } _errorMessage = null; @@ -183,11 +228,16 @@ class ChatProvider with ChangeNotifier { _startPolling(accessToken, chatId); return true; } else { + // Roll back the optimistic message on failure. + _rollbackOptimisticMessage(optimisticId, chatId); _errorMessage = result['error'] ?? 'Failed to send message'; return false; } } catch (e) { - _errorMessage = 'Error: ${e.toString()}'; + // Roll back the optimistic message on error. + _rollbackOptimisticMessage(optimisticId, chatId); + debugPrint('sendMessage error: $e'); + _errorMessage = 'Something went wrong. Please try again.'; return false; } finally { _isSendingMessage = false; @@ -217,15 +267,23 @@ class ChatProvider with ChangeNotifier { _chats[index] = updatedChat; } - // Update current chat if it's the same + // Update current chat if it's the same. + // Preserve existing messages — the title-update response may omit them. if (_currentChat != null && _currentChat!.id == chatId) { - _currentChat = updatedChat; + final Chat newChat; + if (updatedChat.messages.isEmpty) { + newChat = updatedChat.copyWith(messages: _currentChat!.messages); + } else { + newChat = updatedChat; + } + _currentChat = newChat; } notifyListeners(); } } catch (e) { - _errorMessage = 'Error: ${e.toString()}'; + debugPrint('updateChatTitle error: $e'); + _errorMessage = 'Something went wrong. Please try again.'; notifyListeners(); } } @@ -256,7 +314,8 @@ class ChatProvider with ChangeNotifier { return false; } } catch (e) { - _errorMessage = 'Error: ${e.toString()}'; + debugPrint('deleteChat error: $e'); + _errorMessage = 'Something went wrong. Please try again.'; notifyListeners(); return false; } @@ -266,6 +325,7 @@ class ChatProvider with ChangeNotifier { void _startPolling(String accessToken, String chatId) { _pollingTimer?.cancel(); _lastAssistantContentLength = null; + _stablePollingCount = 0; _isWaitingForResponse = true; _pollingStartTime = DateTime.now(); notifyListeners(); @@ -289,6 +349,7 @@ class ChatProvider with ChangeNotifier { _isPollingRequestInFlight = false; _isWaitingForResponse = false; _lastAssistantContentLength = null; + _stablePollingCount = 0; } /// Poll for updates @@ -335,13 +396,6 @@ 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(); } @@ -362,6 +416,7 @@ class ChatProvider with ChangeNotifier { if (newLen > (previousLen ?? -1)) { _lastAssistantContentLength = newLen; + _stablePollingCount = 0; if (newLen > 0) { // Content is growing — reset the inactivity clock. _pollingStartTime = DateTime.now(); @@ -369,11 +424,16 @@ class ChatProvider with ChangeNotifier { } // newLen == 0: empty placeholder, keep polling } else if (newLen > 0) { - // Content stable and non-empty: no growth since last poll — done. - _stopPolling(); - _lastAssistantContentLength = null; - notifyListeners(); - return; + // Content stable and non-empty. + // Require 2 consecutive stable polls before declaring done, to avoid + // stopping prematurely on a brief server-side generation pause. + _stablePollingCount++; + if (_stablePollingCount >= 2) { + _stopPolling(); + _lastAssistantContentLength = null; + notifyListeners(); + return; + } } // newLen == 0 with previousLen already 0: still empty, keep polling } diff --git a/mobile/lib/screens/chat_conversation_screen.dart b/mobile/lib/screens/chat_conversation_screen.dart index 9f9b3c73c..a18aafb5b 100644 --- a/mobile/lib/screens/chat_conversation_screen.dart +++ b/mobile/lib/screens/chat_conversation_screen.dart @@ -6,6 +6,7 @@ import '../models/chat.dart'; import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import '../models/message.dart'; +import '../constants/suggested_questions.dart'; import '../widgets/typing_indicator.dart'; class _SendMessageIntent extends Intent { @@ -86,6 +87,14 @@ class _ChatConversationScreenState extends State { } } + Future _sendSuggestedQuestion(String question) async { + if (!mounted) return; + final chatProvider = Provider.of(context, listen: false); + if (chatProvider.isSendingMessage || chatProvider.isWaitingForResponse) return; + _messageController.text = question; + await _sendMessage(); + } + Future _loadChat({bool forceRefresh = false}) async { if (_chatId == null) return; @@ -322,26 +331,45 @@ class _ChatConversationScreenState extends State { ); } - final messages = chatProvider.currentChat?.messages ?? []; + final allMessages = chatProvider.currentChat?.messages ?? []; + // While waiting for the AI response, hide the last (partial/streaming) + // assistant message so the typing indicator shows instead of partial content. + // The full response is revealed once polling detects stable content. + final messages = chatProvider.isWaitingForResponse + ? allMessages.where((m) { + return !(m.isAssistant && m == allMessages.lastOrNull); + }).toList() + : allMessages; + final firstName = + Provider.of(context, listen: true).user?.firstName; return Column( children: [ Expanded( - 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, - ); - }, - ), + child: messages.isEmpty && + !chatProvider.isLoading && + !chatProvider.isSendingMessage && + !chatProvider.isWaitingForResponse + ? _EmptyState( + firstName: firstName, + isSending: false, + onQuestionTap: _sendSuggestedQuestion, + ) + : 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 @@ -551,6 +579,58 @@ class _MessageBubble extends StatelessWidget { } } +class _EmptyState extends StatelessWidget { + final String? firstName; + final bool isSending; + final void Function(String) onQuestionTap; + + const _EmptyState({ + required this.firstName, + required this.isSending, + required this.onQuestionTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final name = (firstName ?? '').trim(); + final greeting = name.isNotEmpty ? 'Hi $name, how can I help?' : 'How can I help?'; + + return ListView( + padding: const EdgeInsets.all(24), + children: [ + const SizedBox(height: 32), + Text( + greeting, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ...suggestedQuestions.map( + (q) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: OutlinedButton.icon( + onPressed: isSending ? null : () => onQuestionTap(q.text), + icon: Icon(q.icon, size: 20), + label: Text(q.text, textAlign: TextAlign.left), + style: OutlinedButton.styleFrom( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + foregroundColor: colorScheme.onSurface, + ), + ), + ), + ), + ], + ); + } +} + class _TypingIndicatorBubble extends StatelessWidget { const _TypingIndicatorBubble();