mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 13:04:56 +00:00
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:
committed by
GitHub
parent
12d799e0b8
commit
04b0122dbf
14
mobile/lib/constants/suggested_questions.dart
Normal file
14
mobile/lib/constants/suggested_questions.dart
Normal 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?'),
|
||||
];
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user