feat(mobile): add suggested questions to empty chat screen (#1773)

* 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 <noreply@anthropic.com>

* 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<ChatProvider>
  builder (listen: false → listen: true) so greeting updates when
  user's firstName changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tristan the Katana
2026-05-12 20:57:13 +03:00
committed by GitHub
parent 12d799e0b8
commit 04b0122dbf
3 changed files with 194 additions and 40 deletions

View File

@@ -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?'),
];

View File

@@ -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<Chat> 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<bool> 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
}

View File

@@ -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<ChatConversationScreen> {
}
}
Future<void> _sendSuggestedQuestion(String question) async {
if (!mounted) return;
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
if (chatProvider.isSendingMessage || chatProvider.isWaitingForResponse) return;
_messageController.text = question;
await _sendMessage();
}
Future<void> _loadChat({bool forceRefresh = false}) async {
if (_chatId == null) return;
@@ -322,26 +331,45 @@ class _ChatConversationScreenState extends State<ChatConversationScreen> {
);
}
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<AuthProvider>(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();