From f52b3fceb6ca40e1b0d9d2ff51f9048c44e22e83 Mon Sep 17 00:00:00 2001 From: Lazy Bone <89256478+dwvwdv@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:45:33 +0800 Subject: [PATCH] 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 --- app/controllers/api/v1/chats_controller.rb | 7 +- app/controllers/api/v1/messages_controller.rb | 7 +- mobile/lib/main.dart | 8 +- mobile/lib/models/chat.dart | 77 ++++ mobile/lib/models/message.dart | 56 +++ mobile/lib/models/tool_call.dart | 53 +++ mobile/lib/providers/chat_provider.dart | 301 ++++++++++++ .../lib/providers/transactions_provider.dart | 14 +- .../lib/screens/chat_conversation_screen.dart | 436 ++++++++++++++++++ mobile/lib/screens/chat_list_screen.dart | 298 ++++++++++++ .../lib/screens/main_navigation_screen.dart | 103 +++++ mobile/lib/screens/settings_screen.dart | 122 +++++ mobile/lib/services/chat_service.dart | 395 ++++++++++++++++ 13 files changed, 1869 insertions(+), 8 deletions(-) create mode 100644 mobile/lib/models/chat.dart create mode 100644 mobile/lib/models/message.dart create mode 100644 mobile/lib/models/tool_call.dart create mode 100644 mobile/lib/providers/chat_provider.dart create mode 100644 mobile/lib/screens/chat_conversation_screen.dart create mode 100644 mobile/lib/screens/chat_list_screen.dart create mode 100644 mobile/lib/screens/main_navigation_screen.dart create mode 100644 mobile/lib/screens/settings_screen.dart create mode 100644 mobile/lib/services/chat_service.dart diff --git a/app/controllers/api/v1/chats_controller.rb b/app/controllers/api/v1/chats_controller.rb index 87094c26d..ad1a2b228 100644 --- a/app/controllers/api/v1/chats_controller.rb +++ b/app/controllers/api/v1/chats_controller.rb @@ -28,7 +28,12 @@ class Api::V1::ChatsController < Api::V1::BaseController ) if @message.save - AssistantResponseJob.perform_later(@message) + # NOTE: Commenting out duplicate job enqueue to fix mobile app receiving duplicate AI responses + # UserMessage model already triggers AssistantResponseJob via after_create_commit callback + # in app/models/user_message.rb:10-12, so this manual enqueue causes the job to run twice, + # resulting in duplicate AI responses with different content and wasted tokens. + # See: https://github.com/dwvwdv/sure (mobile app integration issue) + # AssistantResponseJob.perform_later(@message) render :show, status: :created else @chat.destroy diff --git a/app/controllers/api/v1/messages_controller.rb b/app/controllers/api/v1/messages_controller.rb index 305ee09df..f9f3b8388 100644 --- a/app/controllers/api/v1/messages_controller.rb +++ b/app/controllers/api/v1/messages_controller.rb @@ -13,7 +13,12 @@ class Api::V1::MessagesController < Api::V1::BaseController ) if @message.save - AssistantResponseJob.perform_later(@message) + # NOTE: Commenting out duplicate job enqueue to fix mobile app receiving duplicate AI responses + # UserMessage model already triggers AssistantResponseJob via after_create_commit callback + # in app/models/user_message.rb:10-12, so this manual enqueue causes the job to run twice, + # resulting in duplicate AI responses with different content and wasted tokens. + # See: https://github.com/dwvwdv/sure (mobile app integration issue) + # AssistantResponseJob.perform_later(@message) render :show, status: :created else render json: { error: "Failed to create message", details: @message.errors.full_messages }, status: :unprocessable_entity diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 07c587700..d9ef30c70 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,9 +3,10 @@ import 'package:provider/provider.dart'; import 'providers/auth_provider.dart'; import 'providers/accounts_provider.dart'; import 'providers/transactions_provider.dart'; +import 'providers/chat_provider.dart'; import 'screens/backend_config_screen.dart'; import 'screens/login_screen.dart'; -import 'screens/dashboard_screen.dart'; +import 'screens/main_navigation_screen.dart'; import 'services/api_config.dart'; import 'services/connectivity_service.dart'; import 'services/log_service.dart'; @@ -30,6 +31,7 @@ class SureApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => LogService.instance), ChangeNotifierProvider(create: (_) => ConnectivityService()), ChangeNotifierProvider(create: (_) => AuthProvider()), + ChangeNotifierProvider(create: (_) => ChatProvider()), ChangeNotifierProxyProvider( create: (_) => AccountsProvider(), update: (_, connectivityService, accountsProvider) { @@ -126,7 +128,7 @@ class SureApp extends StatelessWidget { routes: { '/config': (context) => const BackendConfigScreen(), '/login': (context) => const LoginScreen(), - '/dashboard': (context) => const DashboardScreen(), + '/home': (context) => const MainNavigationScreen(), }, home: const AppWrapper(), ), @@ -201,7 +203,7 @@ class _AppWrapperState extends State { } if (authProvider.isAuthenticated) { - return const DashboardScreen(); + return const MainNavigationScreen(); } return LoginScreen( diff --git a/mobile/lib/models/chat.dart b/mobile/lib/models/chat.dart new file mode 100644 index 000000000..480398f94 --- /dev/null +++ b/mobile/lib/models/chat.dart @@ -0,0 +1,77 @@ +import 'message.dart'; + +class Chat { + final String id; + final String title; + final String? error; + final DateTime createdAt; + final DateTime updatedAt; + final List messages; + final int? messageCount; + final DateTime? lastMessageAt; + + Chat({ + required this.id, + required this.title, + this.error, + required this.createdAt, + required this.updatedAt, + this.messages = const [], + this.messageCount, + this.lastMessageAt, + }); + + factory Chat.fromJson(Map json) { + return Chat( + id: json['id'].toString(), + title: json['title'] as String, + error: json['error'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + messages: json['messages'] != null + ? (json['messages'] as List) + .map((m) => Message.fromJson(m as Map)) + .toList() + : [], + messageCount: json['message_count'] as int?, + lastMessageAt: json['last_message_at'] != null + ? DateTime.parse(json['last_message_at'] as String) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'error': error, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'messages': messages.map((m) => m.toJson()).toList(), + 'message_count': messageCount, + 'last_message_at': lastMessageAt?.toIso8601String(), + }; + } + + Chat copyWith({ + String? id, + String? title, + String? error, + DateTime? createdAt, + DateTime? updatedAt, + List? messages, + int? messageCount, + DateTime? lastMessageAt, + }) { + return Chat( + id: id ?? this.id, + title: title ?? this.title, + error: error ?? this.error, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + messages: messages ?? this.messages, + messageCount: messageCount ?? this.messageCount, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + ); + } +} diff --git a/mobile/lib/models/message.dart b/mobile/lib/models/message.dart new file mode 100644 index 000000000..d3b71949e --- /dev/null +++ b/mobile/lib/models/message.dart @@ -0,0 +1,56 @@ +import 'tool_call.dart'; + +class Message { + final String id; + final String type; + final String role; + final String content; + final String? model; + final DateTime createdAt; + final DateTime updatedAt; + final List? toolCalls; + + Message({ + required this.id, + required this.type, + required this.role, + required this.content, + this.model, + required this.createdAt, + required this.updatedAt, + this.toolCalls, + }); + + factory Message.fromJson(Map json) { + return Message( + id: json['id'].toString(), + type: json['type'] as String, + role: json['role'] as String, + content: json['content'] as String, + model: json['model'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + toolCalls: json['tool_calls'] != null + ? (json['tool_calls'] as List) + .map((tc) => ToolCall.fromJson(tc as Map)) + .toList() + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'type': type, + 'role': role, + 'content': content, + 'model': model, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'tool_calls': toolCalls?.map((tc) => tc.toJson()).toList(), + }; + } + + bool get isUser => role == 'user'; + bool get isAssistant => role == 'assistant'; +} diff --git a/mobile/lib/models/tool_call.dart b/mobile/lib/models/tool_call.dart new file mode 100644 index 000000000..606c3d0fb --- /dev/null +++ b/mobile/lib/models/tool_call.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +class ToolCall { + final String id; + final String functionName; + final Map functionArguments; + final Map? functionResult; + final DateTime createdAt; + + ToolCall({ + required this.id, + required this.functionName, + required this.functionArguments, + this.functionResult, + required this.createdAt, + }); + + factory ToolCall.fromJson(Map json) { + return ToolCall( + id: json['id'].toString(), + functionName: json['function_name'] as String, + functionArguments: _parseJsonField(json['function_arguments']), + functionResult: json['function_result'] != null + ? _parseJsonField(json['function_result']) + : null, + createdAt: DateTime.parse(json['created_at'] as String), + ); + } + + static Map _parseJsonField(dynamic field) { + if (field == null) return {}; + if (field is Map) return field; + if (field is String) { + try { + final parsed = jsonDecode(field); + return parsed is Map ? parsed : {}; + } catch (e) { + return {}; + } + } + return {}; + } + + Map toJson() { + return { + 'id': id, + 'function_name': functionName, + 'function_arguments': functionArguments, + 'function_result': functionResult, + 'created_at': createdAt.toIso8601String(), + }; + } +} diff --git a/mobile/lib/providers/chat_provider.dart b/mobile/lib/providers/chat_provider.dart new file mode 100644 index 000000000..2760aec23 --- /dev/null +++ b/mobile/lib/providers/chat_provider.dart @@ -0,0 +1,301 @@ +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 _chats = []; + Chat? _currentChat; + bool _isLoading = false; + bool _isSendingMessage = false; + String? _errorMessage; + Timer? _pollingTimer; + + List get chats => _chats; + Chat? get currentChat => _currentChat; + bool get isLoading => _isLoading; + bool get isSendingMessage => _isSendingMessage; + String? get errorMessage => _errorMessage; + + /// Fetch list of chats + Future 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; + _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 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 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 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 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 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 _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(); + } +} diff --git a/mobile/lib/providers/transactions_provider.dart b/mobile/lib/providers/transactions_provider.dart index 9af195889..54e682178 100644 --- a/mobile/lib/providers/transactions_provider.dart +++ b/mobile/lib/providers/transactions_provider.dart @@ -19,6 +19,7 @@ class TransactionsProvider with ChangeNotifier { String? _error; ConnectivityService? _connectivityService; String? _lastAccessToken; + String? _currentAccountId; // Track current account for filtering bool _isAutoSyncing = false; bool _isListenerAttached = false; bool _isDisposed = false; @@ -88,6 +89,7 @@ class TransactionsProvider with ChangeNotifier { bool forceSync = false, }) async { _lastAccessToken = accessToken; // Store for auto-sync + _currentAccountId = accountId; // Track current account _isLoading = true; _error = null; notifyListeners(); @@ -255,7 +257,9 @@ class TransactionsProvider with ChangeNotifier { await _offlineStorage.markTransactionForDeletion(transactionId); // Reload from storage to update UI with pending delete status - final updatedTransactions = await _offlineStorage.getTransactions(); + final updatedTransactions = await _offlineStorage.getTransactions( + accountId: _currentAccountId, + ); _transactions = updatedTransactions; notifyListeners(); return true; @@ -303,7 +307,9 @@ class TransactionsProvider with ChangeNotifier { } // Reload from storage to update UI with pending delete status - final updatedTransactions = await _offlineStorage.getTransactions(); + final updatedTransactions = await _offlineStorage.getTransactions( + accountId: _currentAccountId, + ); _transactions = updatedTransactions; notifyListeners(); return true; @@ -328,7 +334,9 @@ class TransactionsProvider with ChangeNotifier { if (success) { // Reload from storage to update UI - final updatedTransactions = await _offlineStorage.getTransactions(); + final updatedTransactions = await _offlineStorage.getTransactions( + accountId: _currentAccountId, + ); _transactions = updatedTransactions; _error = null; notifyListeners(); diff --git a/mobile/lib/screens/chat_conversation_screen.dart b/mobile/lib/screens/chat_conversation_screen.dart new file mode 100644 index 000000000..0de3fee61 --- /dev/null +++ b/mobile/lib/screens/chat_conversation_screen.dart @@ -0,0 +1,436 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import '../providers/chat_provider.dart'; +import '../models/message.dart'; + +class ChatConversationScreen extends StatefulWidget { + final String chatId; + + const ChatConversationScreen({ + super.key, + required this.chatId, + }); + + @override + State createState() => _ChatConversationScreenState(); +} + +class _ChatConversationScreenState extends State { + final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _loadChat(); + } + + @override + void dispose() { + _messageController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _loadChat() async { + final authProvider = Provider.of(context, listen: false); + final chatProvider = Provider.of(context, listen: false); + + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null) { + await authProvider.logout(); + return; + } + + await chatProvider.fetchChat( + accessToken: accessToken, + chatId: widget.chatId, + ); + + // Scroll to bottom after loading + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + } + + Future _sendMessage() async { + final content = _messageController.text.trim(); + if (content.isEmpty) return; + + final authProvider = Provider.of(context, listen: false); + final chatProvider = Provider.of(context, listen: false); + + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null) { + await authProvider.logout(); + return; + } + + // Clear input field immediately + _messageController.clear(); + + await chatProvider.sendMessage( + accessToken: accessToken, + chatId: widget.chatId, + content: content, + ); + + // Scroll to bottom after sending + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + Future _editTitle() async { + final chatProvider = Provider.of(context, listen: false); + final currentTitle = chatProvider.currentChat?.title ?? ''; + + final newTitle = await showDialog( + context: context, + builder: (context) { + final controller = TextEditingController(text: currentTitle); + return AlertDialog( + title: const Text('Edit Title'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Chat Title', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('Save'), + ), + ], + ); + }, + ); + + if (newTitle != null && newTitle.isNotEmpty && newTitle != currentTitle && mounted) { + final authProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken != null) { + await chatProvider.updateChatTitle( + accessToken: accessToken, + chatId: widget.chatId, + title: newTitle, + ); + } + } + } + + String _formatTime(DateTime dateTime) { + final hour = dateTime.hour.toString().padLeft(2, '0'); + final minute = dateTime.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: Consumer( + builder: (context, chatProvider, _) { + return GestureDetector( + onTap: _editTitle, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + chatProvider.currentChat?.title ?? 'Chat', + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.edit, size: 18), + ], + ), + ); + }, + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadChat, + tooltip: 'Refresh', + ), + ], + ), + body: Consumer( + builder: (context, chatProvider, _) { + if (chatProvider.isLoading && chatProvider.currentChat == null) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (chatProvider.errorMessage != null && chatProvider.currentChat == 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, + ), + const SizedBox(height: 16), + Text( + 'Failed to load chat', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + chatProvider.errorMessage!, + style: TextStyle(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _loadChat, + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + ), + ], + ), + ), + ); + } + + final messages = chatProvider.currentChat?.messages ?? []; + + 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, + ), + ), + ], + ), + ), + + // Message input + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Type a message...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + maxLines: null, + textCapitalization: TextCapitalization.sentences, + onSubmitted: (_) => _sendMessage(), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send), + onPressed: chatProvider.isSendingMessage ? null : _sendMessage, + color: colorScheme.primary, + iconSize: 28, + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} + +class _MessageBubble extends StatelessWidget { + final Message message; + final String Function(DateTime) formatTime; + + const _MessageBubble({ + required this.message, + required this.formatTime, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isUser = message.isUser; + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isUser) + CircleAvatar( + radius: 16, + backgroundColor: colorScheme.primaryContainer, + child: Icon( + Icons.smart_toy, + size: 18, + color: colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isUser ? colorScheme.primary : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.content, + style: TextStyle( + color: isUser ? colorScheme.onPrimary : colorScheme.onSurfaceVariant, + ), + ), + if (message.toolCalls != null && message.toolCalls!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: message.toolCalls!.map((toolCall) { + return Chip( + label: Text( + toolCall.functionName, + style: const TextStyle(fontSize: 11), + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ); + }).toList(), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + formatTime(message.createdAt), + style: TextStyle( + fontSize: 11, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + if (isUser) + CircleAvatar( + radius: 16, + backgroundColor: colorScheme.primary, + child: Icon( + Icons.person, + size: 18, + color: colorScheme.onPrimary, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/screens/chat_list_screen.dart b/mobile/lib/screens/chat_list_screen.dart new file mode 100644 index 000000000..1039b3309 --- /dev/null +++ b/mobile/lib/screens/chat_list_screen.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import '../providers/chat_provider.dart'; +import 'chat_conversation_screen.dart'; + +class ChatListScreen extends StatefulWidget { + const ChatListScreen({super.key}); + + @override + State createState() => _ChatListScreenState(); +} + +class _ChatListScreenState extends State { + @override + void initState() { + super.initState(); + _loadChats(); + } + + Future _loadChats() async { + final authProvider = Provider.of(context, listen: false); + final chatProvider = Provider.of(context, listen: false); + + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null) { + await authProvider.logout(); + return; + } + + await chatProvider.fetchChats(accessToken: accessToken); + } + + Future _handleRefresh() async { + await _loadChats(); + } + + Future _createNewChat() async { + final authProvider = Provider.of(context, listen: false); + final chatProvider = Provider.of(context, listen: false); + + 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: 'New Chat', + ); + + // 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, + ), + ); + } + } + + String _formatDateTime(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inHours < 1) { + return '${difference.inMinutes}m ago'; + } else if (difference.inDays < 1) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return '${dateTime.day}/${dateTime.month}/${dateTime.year}'; + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('AI Assistant'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _handleRefresh, + tooltip: 'Refresh', + ), + ], + ), + body: Consumer( + builder: (context, chatProvider, _) { + if (chatProvider.isLoading && chatProvider.chats.isEmpty) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (chatProvider.errorMessage != null && chatProvider.chats.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: colorScheme.error, + ), + const SizedBox(height: 16), + Text( + 'Failed to load chats', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + chatProvider.errorMessage!, + style: TextStyle(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _handleRefresh, + icon: const Icon(Icons.refresh), + label: const Text('Try Again'), + ), + ], + ), + ), + ); + } + + if (chatProvider.chats.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No chats yet', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Start a new conversation with the AI assistant.', + style: TextStyle(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + return RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: chatProvider.chats.length, + itemBuilder: (context, index) { + final chat = chatProvider.chats[index]; + return Dismissible( + key: Key(chat.id), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: const Icon( + Icons.delete, + color: Colors.white, + ), + ), + confirmDismiss: (direction) async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Chat'), + content: Text('Are you sure you want to delete "${chat.title}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + }, + onDismissed: (direction) async { + final authProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken != null) { + await chatProvider.deleteChat( + accessToken: accessToken, + chatId: chat.id, + ); + } + }, + child: ListTile( + leading: CircleAvatar( + backgroundColor: colorScheme.primaryContainer, + child: Icon( + Icons.chat, + color: colorScheme.onPrimaryContainer, + ), + ), + title: Text( + chat.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: chat.lastMessageAt != null + ? Text(_formatDateTime(chat.lastMessageAt!)) + : null, + trailing: chat.messageCount != null + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${chat.messageCount}', + style: TextStyle( + color: colorScheme.onSecondaryContainer, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ) + : null, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatConversationScreen(chatId: chat.id), + ), + ); + _loadChats(); + }, + ), + ); + }, + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: _createNewChat, + tooltip: 'New Chat', + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/mobile/lib/screens/main_navigation_screen.dart b/mobile/lib/screens/main_navigation_screen.dart new file mode 100644 index 000000000..074caee24 --- /dev/null +++ b/mobile/lib/screens/main_navigation_screen.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'dashboard_screen.dart'; +import 'chat_list_screen.dart'; +import 'settings_screen.dart'; + +class MainNavigationScreen extends StatefulWidget { + const MainNavigationScreen({super.key}); + + @override + State createState() => _MainNavigationScreenState(); +} + +class _MainNavigationScreenState extends State { + int _currentIndex = 0; + + final List _screens = [ + const DashboardScreen(), + const ChatListScreen(), + const PlaceholderScreen(), + const SettingsScreen(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _currentIndex, + children: _screens, + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: (index) { + setState(() { + _currentIndex = index; + }); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'AI Chat', + ), + NavigationDestination( + icon: Icon(Icons.more_horiz), + selectedIcon: Icon(Icons.more_horiz), + label: 'More', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ); + } +} + +class PlaceholderScreen extends StatelessWidget { + const PlaceholderScreen({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('More'), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.construction, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'Coming Soon', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'This section is under development.', + style: TextStyle(color: colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart new file mode 100644 index 000000000..5ff79ae69 --- /dev/null +++ b/mobile/lib/screens/settings_screen.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + Future _handleLogout(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Sign Out'), + content: const Text('Are you sure you want to sign out?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Sign Out'), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + final authProvider = Provider.of(context, listen: false); + await authProvider.logout(); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final authProvider = Provider.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + ), + body: ListView( + children: [ + // User info section + Container( + padding: const EdgeInsets.all(16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 30, + backgroundColor: colorScheme.primary, + child: Text( + authProvider.user?.displayName[0].toUpperCase() ?? 'U', + style: TextStyle( + fontSize: 24, + color: colorScheme.onPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + authProvider.user?.displayName ?? 'User', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + authProvider.user?.email ?? '', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ), + + // App version + const ListTile( + leading: Icon(Icons.info_outline), + title: Text('App Version'), + subtitle: Text('1.0.0'), + ), + + const Divider(), + + // Sign out button + Padding( + padding: const EdgeInsets.all(16), + child: ElevatedButton.icon( + onPressed: () => _handleLogout(context), + icon: const Icon(Icons.logout), + label: const Text('Sign Out'), + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/services/chat_service.dart b/mobile/lib/services/chat_service.dart new file mode 100644 index 000000000..bb2366c15 --- /dev/null +++ b/mobile/lib/services/chat_service.dart @@ -0,0 +1,395 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/chat.dart'; +import '../models/message.dart'; +import 'api_config.dart'; + +class ChatService { + /// Get list of chats with pagination + Future> getChats({ + required String accessToken, + int page = 1, + int perPage = 25, + }) async { + try { + final url = Uri.parse( + '${ApiConfig.baseUrl}/api/v1/chats?page=$page&per_page=$perPage', + ); + + final response = await http.get( + url, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + + final chatsList = (responseData['chats'] as List) + .map((json) => Chat.fromJson(json)) + .toList(); + + return { + 'success': true, + 'chats': chatsList, + 'pagination': responseData['pagination'], + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 403) { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': 'feature_disabled', + 'message': responseData['message'] ?? 'AI features not enabled', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to fetch chats', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + /// Get a specific chat with messages + Future> getChat({ + required String accessToken, + required String chatId, + int page = 1, + int perPage = 50, + }) async { + try { + final url = Uri.parse( + '${ApiConfig.baseUrl}/api/v1/chats/$chatId?page=$page&per_page=$perPage', + ); + + final response = await http.get( + url, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + final chat = Chat.fromJson(responseData); + + return { + 'success': true, + 'chat': chat, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 404) { + return { + 'success': false, + 'error': 'not_found', + 'message': 'Chat not found', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to fetch chat', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + /// Create a new chat with optional initial message + Future> createChat({ + required String accessToken, + String? title, + String? initialMessage, + String model = 'gpt-4', + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats'); + + final body = { + 'model': model, + }; + + if (title != null) { + body['title'] = title; + } + + if (initialMessage != null) { + body['message'] = initialMessage; + } + + final response = await http.post( + url, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: jsonEncode(body), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 201) { + final responseData = jsonDecode(response.body); + final chat = Chat.fromJson(responseData); + + return { + 'success': true, + 'chat': chat, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 403) { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': 'feature_disabled', + 'message': responseData['message'] ?? 'AI features not enabled', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to create chat', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + /// Send a message to a chat + Future> sendMessage({ + required String accessToken, + required String chatId, + required String content, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId/messages'); + + final response = await http.post( + url, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'content': content, + }), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 201) { + final responseData = jsonDecode(response.body); + final message = Message.fromJson(responseData); + + return { + 'success': true, + 'message': message, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 404) { + return { + 'success': false, + 'error': 'not_found', + 'message': 'Chat not found', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to send message', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + /// Update chat title + Future> updateChat({ + required String accessToken, + required String chatId, + required String title, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId'); + + final response = await http.patch( + url, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'title': title, + }), + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + final chat = Chat.fromJson(responseData); + + return { + 'success': true, + 'chat': chat, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 404) { + return { + 'success': false, + 'error': 'not_found', + 'message': 'Chat not found', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to update chat', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + /// Delete a chat + Future> deleteChat({ + required String accessToken, + required String chatId, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId'); + + final response = await http.delete( + url, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 204) { + return { + 'success': true, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 404) { + return { + 'success': false, + 'error': 'not_found', + 'message': 'Chat not found', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to delete chat', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + /// Retry the last assistant response in a chat + Future> retryMessage({ + required String accessToken, + required String chatId, + }) async { + try { + final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId/messages/retry'); + + final response = await http.post( + url, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 202) { + return { + 'success': true, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + 'message': 'Session expired. Please login again.', + }; + } else if (response.statusCode == 404) { + return { + 'success': false, + 'error': 'not_found', + 'message': 'Chat not found', + }; + } else { + final responseData = jsonDecode(response.body); + return { + 'success': false, + 'error': responseData['error'] ?? 'Failed to retry message', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } +}