mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ConnectivityService, AccountsProvider>(
|
||||
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<AppWrapper> {
|
||||
}
|
||||
|
||||
if (authProvider.isAuthenticated) {
|
||||
return const DashboardScreen();
|
||||
return const MainNavigationScreen();
|
||||
}
|
||||
|
||||
return LoginScreen(
|
||||
|
||||
77
mobile/lib/models/chat.dart
Normal file
77
mobile/lib/models/chat.dart
Normal file
@@ -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<Message> 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<String, dynamic> 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<String, dynamic>))
|
||||
.toList()
|
||||
: [],
|
||||
messageCount: json['message_count'] as int?,
|
||||
lastMessageAt: json['last_message_at'] != null
|
||||
? DateTime.parse(json['last_message_at'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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<Message>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
56
mobile/lib/models/message.dart
Normal file
56
mobile/lib/models/message.dart
Normal file
@@ -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<ToolCall>? 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<String, dynamic> 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<String, dynamic>))
|
||||
.toList()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> 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';
|
||||
}
|
||||
53
mobile/lib/models/tool_call.dart
Normal file
53
mobile/lib/models/tool_call.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class ToolCall {
|
||||
final String id;
|
||||
final String functionName;
|
||||
final Map<String, dynamic> functionArguments;
|
||||
final Map<String, dynamic>? functionResult;
|
||||
final DateTime createdAt;
|
||||
|
||||
ToolCall({
|
||||
required this.id,
|
||||
required this.functionName,
|
||||
required this.functionArguments,
|
||||
this.functionResult,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ToolCall.fromJson(Map<String, dynamic> 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<String, dynamic> _parseJsonField(dynamic field) {
|
||||
if (field == null) return {};
|
||||
if (field is Map<String, dynamic>) return field;
|
||||
if (field is String) {
|
||||
try {
|
||||
final parsed = jsonDecode(field);
|
||||
return parsed is Map<String, dynamic> ? parsed : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'function_name': functionName,
|
||||
'function_arguments': functionArguments,
|
||||
'function_result': functionResult,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
301
mobile/lib/providers/chat_provider.dart
Normal file
301
mobile/lib/providers/chat_provider.dart
Normal file
@@ -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<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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
436
mobile/lib/screens/chat_conversation_screen.dart
Normal file
436
mobile/lib/screens/chat_conversation_screen.dart
Normal file
@@ -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<ChatConversationScreen> createState() => _ChatConversationScreenState();
|
||||
}
|
||||
|
||||
class _ChatConversationScreenState extends State<ChatConversationScreen> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadChat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadChat() async {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final chatProvider = Provider.of<ChatProvider>(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<void> _sendMessage() async {
|
||||
final content = _messageController.text.trim();
|
||||
if (content.isEmpty) return;
|
||||
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final chatProvider = Provider.of<ChatProvider>(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<void> _editTitle() async {
|
||||
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
|
||||
final currentTitle = chatProvider.currentChat?.title ?? '';
|
||||
|
||||
final newTitle = await showDialog<String>(
|
||||
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<AuthProvider>(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<ChatProvider>(
|
||||
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<ChatProvider>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
298
mobile/lib/screens/chat_list_screen.dart
Normal file
298
mobile/lib/screens/chat_list_screen.dart
Normal file
@@ -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<ChatListScreen> createState() => _ChatListScreenState();
|
||||
}
|
||||
|
||||
class _ChatListScreenState extends State<ChatListScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadChats();
|
||||
}
|
||||
|
||||
Future<void> _loadChats() async {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final chatProvider = Provider.of<ChatProvider>(context, listen: false);
|
||||
|
||||
final accessToken = await authProvider.getValidAccessToken();
|
||||
if (accessToken == null) {
|
||||
await authProvider.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
await chatProvider.fetchChats(accessToken: accessToken);
|
||||
}
|
||||
|
||||
Future<void> _handleRefresh() async {
|
||||
await _loadChats();
|
||||
}
|
||||
|
||||
Future<void> _createNewChat() async {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final chatProvider = Provider.of<ChatProvider>(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<ChatProvider>(
|
||||
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<bool>(
|
||||
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<AuthProvider>(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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
103
mobile/lib/screens/main_navigation_screen.dart
Normal file
103
mobile/lib/screens/main_navigation_screen.dart
Normal file
@@ -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<MainNavigationScreen> createState() => _MainNavigationScreenState();
|
||||
}
|
||||
|
||||
class _MainNavigationScreenState extends State<MainNavigationScreen> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
final List<Widget> _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
122
mobile/lib/screens/settings_screen.dart
Normal file
122
mobile/lib/screens/settings_screen.dart
Normal file
@@ -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<void> _handleLogout(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<AuthProvider>(context, listen: false);
|
||||
await authProvider.logout();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final authProvider = Provider.of<AuthProvider>(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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
395
mobile/lib/services/chat_service.dart
Normal file
395
mobile/lib/services/chat_service.dart
Normal file
@@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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 = <String, dynamic>{
|
||||
'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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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()}',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user