Files
sure/mobile/lib/providers/chat_provider.dart
Lazy Bone f52b3fceb6 feat: implement mobile AI chat feature and fix duplicate response issue (#610)
Backend fixes:
- Fix duplicate AssistantResponseJob triggering causing duplicate AI responses
- UserMessage model already handles job triggering via after_create_commit callback
- Remove redundant job enqueue in chats_controller and messages_controller

Mobile app features:
- Implement complete AI chat interface and conversation management
- Add Chat, Message, and ToolCall data models
- Add ChatProvider for state management with polling mechanism
- Add ChatService to handle all chat-related API requests
- Add chat list screen (ChatListScreen)
- Add conversation detail screen (ChatConversationScreen)
- Refactor navigation structure with bottom navigation bar (MainNavigationScreen)
- Add settings screen (SettingsScreen)
- Optimize TransactionsProvider to support account filtering

Technical details:
- Implement message polling mechanism for real-time AI responses
- Support chat creation, deletion, retry and other operations
- Integrate Material Design 3 design language
- Improve user experience and error handling

Co-authored-by: dwvwdv <dwvwdv@protonmail.com>
2026-01-11 12:45:33 +01:00

302 lines
7.5 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/chat.dart';
import '../models/message.dart';
import '../services/chat_service.dart';
class ChatProvider with ChangeNotifier {
final ChatService _chatService = ChatService();
List<Chat> _chats = [];
Chat? _currentChat;
bool _isLoading = false;
bool _isSendingMessage = false;
String? _errorMessage;
Timer? _pollingTimer;
List<Chat> get chats => _chats;
Chat? get currentChat => _currentChat;
bool get isLoading => _isLoading;
bool get isSendingMessage => _isSendingMessage;
String? get errorMessage => _errorMessage;
/// Fetch list of chats
Future<void> fetchChats({
required String accessToken,
int page = 1,
int perPage = 25,
}) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
final result = await _chatService.getChats(
accessToken: accessToken,
page: page,
perPage: perPage,
);
if (result['success'] == true) {
_chats = result['chats'] as List<Chat>;
_errorMessage = null;
} else {
_errorMessage = result['error'] ?? 'Failed to fetch chats';
}
} catch (e) {
_errorMessage = 'Error: ${e.toString()}';
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Fetch a specific chat with messages
Future<void> fetchChat({
required String accessToken,
required String chatId,
}) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
final result = await _chatService.getChat(
accessToken: accessToken,
chatId: chatId,
);
if (result['success'] == true) {
_currentChat = result['chat'] as Chat;
_errorMessage = null;
} else {
_errorMessage = result['error'] ?? 'Failed to fetch chat';
}
} catch (e) {
_errorMessage = 'Error: ${e.toString()}';
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Create a new chat
Future<Chat?> createChat({
required String accessToken,
String? title,
String? initialMessage,
String model = 'gpt-4',
}) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
final result = await _chatService.createChat(
accessToken: accessToken,
title: title,
initialMessage: initialMessage,
model: model,
);
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) {
_startPolling(accessToken, chat.id);
}
_isLoading = false;
notifyListeners();
return chat;
} else {
_errorMessage = result['error'] ?? 'Failed to create chat';
_isLoading = false;
notifyListeners();
return null;
}
} catch (e) {
_errorMessage = 'Error: ${e.toString()}';
_isLoading = false;
notifyListeners();
return null;
}
}
/// Send a message to the current chat
Future<void> sendMessage({
required String accessToken,
required String chatId,
required String content,
}) async {
_isSendingMessage = true;
_errorMessage = null;
notifyListeners();
try {
final result = await _chatService.sendMessage(
accessToken: accessToken,
chatId: chatId,
content: content,
);
if (result['success'] == true) {
final message = result['message'] as Message;
// Add the message to current chat if it's loaded
if (_currentChat != null && _currentChat!.id == chatId) {
_currentChat = _currentChat!.copyWith(
messages: [..._currentChat!.messages, message],
);
}
_errorMessage = null;
// Start polling for AI response
_startPolling(accessToken, chatId);
} else {
_errorMessage = result['error'] ?? 'Failed to send message';
}
} catch (e) {
_errorMessage = 'Error: ${e.toString()}';
} finally {
_isSendingMessage = false;
notifyListeners();
}
}
/// Update chat title
Future<void> updateChatTitle({
required String accessToken,
required String chatId,
required String title,
}) async {
try {
final result = await _chatService.updateChat(
accessToken: accessToken,
chatId: chatId,
title: title,
);
if (result['success'] == true) {
final updatedChat = result['chat'] as Chat;
// Update in the list
final index = _chats.indexWhere((c) => c.id == chatId);
if (index != -1) {
_chats[index] = updatedChat;
}
// Update current chat if it's the same
if (_currentChat != null && _currentChat!.id == chatId) {
_currentChat = updatedChat;
}
notifyListeners();
}
} catch (e) {
_errorMessage = 'Error: ${e.toString()}';
notifyListeners();
}
}
/// Delete a chat
Future<bool> deleteChat({
required String accessToken,
required String chatId,
}) async {
try {
final result = await _chatService.deleteChat(
accessToken: accessToken,
chatId: chatId,
);
if (result['success'] == true) {
_chats.removeWhere((c) => c.id == chatId);
if (_currentChat != null && _currentChat!.id == chatId) {
_currentChat = null;
}
notifyListeners();
return true;
} else {
_errorMessage = result['error'] ?? 'Failed to delete chat';
notifyListeners();
return false;
}
} catch (e) {
_errorMessage = 'Error: ${e.toString()}';
notifyListeners();
return false;
}
}
/// Start polling for new messages (AI responses)
void _startPolling(String accessToken, String chatId) {
_stopPolling();
_pollingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async {
await _pollForUpdates(accessToken, chatId);
});
}
/// Stop polling
void _stopPolling() {
_pollingTimer?.cancel();
_pollingTimer = null;
}
/// Poll for updates
Future<void> _pollForUpdates(String accessToken, String chatId) async {
try {
final result = await _chatService.getChat(
accessToken: accessToken,
chatId: chatId,
);
if (result['success'] == true) {
final updatedChat = result['chat'] as Chat;
// Check if we have new messages
if (_currentChat != null && _currentChat!.id == chatId) {
final oldMessageCount = _currentChat!.messages.length;
final newMessageCount = updatedChat.messages.length;
if (newMessageCount > oldMessageCount) {
_currentChat = updatedChat;
notifyListeners();
// Check if the last message is from assistant and complete
final lastMessage = updatedChat.messages.lastOrNull;
if (lastMessage != null && lastMessage.isAssistant) {
// Stop polling after getting assistant response
_stopPolling();
}
}
}
}
} catch (e) {
// Silently fail polling errors to avoid interrupting user experience
debugPrint('Polling error: ${e.toString()}');
}
}
/// Clear current chat
void clearCurrentChat() {
_currentChat = null;
_stopPolling();
notifyListeners();
}
@override
void dispose() {
_stopPolling();
super.dispose();
}
}