From e67079e6b86e1dc9a1727f23062b0959da204c80 Mon Sep 17 00:00:00 2001 From: Banghua Zhao Date: Sat, 28 Sep 2024 19:05:44 +1200 Subject: [PATCH] Refactor chat screen using clean architecture --- .gitignore | 2 +- lib/data/core/api_constants.dart | 7 + lib/data/core/network_exceptions.dart | 5 + .../data_sources/chat_remote_data_source.dart | 66 +++++ .../repositories/chat_repository_impl.dart | 15 ++ .../chat_session_repository_imp.dart | 30 +++ lib/domain/entities/chat_session.dart | 33 +++ lib/domain/entities/message.dart | 22 ++ .../chat_repository.dart | 5 + .../chat_session_repository.dart | 9 + lib/domain/usecases/chat_session_usecase.dart | 53 ++++ lib/domain/usecases/chat_usecase.dart | 12 + lib/home/Chat/chat_page.dart | 235 ------------------ lib/home/Chat/chat_service.dart | 71 ------ lib/home/Chat/chat_session.dart | 14 -- lib/home/Chat/chat_session_manager.dart | 42 ---- lib/home/Chat/viewModels/chat_view_model.dart | 139 +++++++++++ lib/home/Chat/views/chat_message_list.dart | 94 +++++++ lib/home/Chat/views/chat_screen.dart | 35 +++ lib/home/Chat/views/chat_session_drawer.dart | 51 ++++ .../Chat/{ => views}/markdown_with_math.dart | 0 lib/home/bottom_navigator.dart | 4 +- lib/injection_container.dart | 35 +++ lib/main.dart | 6 +- pubspec.lock | 8 + pubspec.yaml | 1 + 26 files changed, 625 insertions(+), 369 deletions(-) create mode 100644 lib/data/core/api_constants.dart create mode 100644 lib/data/core/network_exceptions.dart create mode 100644 lib/data/data_sources/chat_remote_data_source.dart create mode 100644 lib/data/repositories/chat_repository_impl.dart create mode 100644 lib/data/repositories/chat_session_repository_imp.dart create mode 100644 lib/domain/entities/chat_session.dart create mode 100644 lib/domain/entities/message.dart create mode 100644 lib/domain/repositories_abstract/chat_repository.dart create mode 100644 lib/domain/repositories_abstract/chat_session_repository.dart create mode 100644 lib/domain/usecases/chat_session_usecase.dart create mode 100644 lib/domain/usecases/chat_usecase.dart delete mode 100644 lib/home/Chat/chat_page.dart delete mode 100644 lib/home/Chat/chat_service.dart delete mode 100644 lib/home/Chat/chat_session.dart delete mode 100644 lib/home/Chat/chat_session_manager.dart create mode 100644 lib/home/Chat/viewModels/chat_view_model.dart create mode 100644 lib/home/Chat/views/chat_message_list.dart create mode 100644 lib/home/Chat/views/chat_screen.dart create mode 100644 lib/home/Chat/views/chat_session_drawer.dart rename lib/home/Chat/{ => views}/markdown_with_math.dart (100%) create mode 100644 lib/injection_container.dart diff --git a/.gitignore b/.gitignore index 902e5ea..6fb7bdc 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,6 @@ ios/fastlane/*p8 ios/fastlane/.env ios/fastlane/report.xml -lib/home/Chat/chat_config.dart +lib/data/core/chat_config.dart coverage diff --git a/lib/data/core/api_constants.dart b/lib/data/core/api_constants.dart new file mode 100644 index 0000000..d9b3157 --- /dev/null +++ b/lib/data/core/api_constants.dart @@ -0,0 +1,7 @@ +import 'package:swiftcomp/data/core/chat_config.dart'; + +class ApiConstants { + static const String baseUrl = 'https://api.openai.com/v1'; + static const String chatEndpoint = '$baseUrl/chat/completions'; + static String apiKey = ChatConfig.apiKey; +} diff --git a/lib/data/core/network_exceptions.dart b/lib/data/core/network_exceptions.dart new file mode 100644 index 0000000..1e4ae34 --- /dev/null +++ b/lib/data/core/network_exceptions.dart @@ -0,0 +1,5 @@ +class NetworkException implements Exception { + final String message; + + NetworkException(this.message); +} diff --git a/lib/data/data_sources/chat_remote_data_source.dart b/lib/data/data_sources/chat_remote_data_source.dart new file mode 100644 index 0000000..f5e9935 --- /dev/null +++ b/lib/data/data_sources/chat_remote_data_source.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../../domain/entities/chat_session.dart'; +import '../../domain/entities/message.dart'; +import '../core/api_constants.dart'; +import '../core/network_exceptions.dart'; + +abstract class ChatRemoteDataSource { + Stream sendMessages(List messages); +} + +class ChatRemoteDataSourceImpl implements ChatRemoteDataSource { + final http.Client client; + + ChatRemoteDataSourceImpl({required this.client}); + + @override + Stream sendMessages(List messages) async* { + final request = http.Request('POST', Uri.parse(ApiConstants.chatEndpoint)) + ..headers.addAll({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ${ApiConstants.apiKey}', + }) + ..body = jsonEncode({ + "model": "gpt-4o", + "stream": true, + 'messages': messages, + }); + + final response = await client.send(request); + + if (response.statusCode == 200) { + final utf8DecodedStream = response.stream.transform(utf8.decoder); + String buffer = ''; + + await for (var chunk in utf8DecodedStream) { + buffer += chunk; + List lines = buffer.split('\n'); + buffer = lines.removeLast(); + + for (var line in lines) { + if (line.startsWith('data:')) { + var jsonString = line.replaceFirst('data: ', '').trim(); + + if (jsonString == '[DONE]') { + return; + } + + try { + final data = jsonDecode(jsonString); + final content = data['choices'][0]['delta']['content'] ?? ''; + if (content.isNotEmpty) { + yield content; + } + } catch (_) { + // Handle JSON parsing errors + continue; + } + } + } + } + } else { + throw NetworkException('Failed to send message: ${response.statusCode}'); + } + } +} diff --git a/lib/data/repositories/chat_repository_impl.dart b/lib/data/repositories/chat_repository_impl.dart new file mode 100644 index 0000000..158029c --- /dev/null +++ b/lib/data/repositories/chat_repository_impl.dart @@ -0,0 +1,15 @@ +import '../../domain/entities/chat_session.dart'; +import '../../domain/entities/message.dart'; +import '../../domain/repositories_abstract/chat_repository.dart'; +import '../data_sources/chat_remote_data_source.dart'; + +class ChatRepositoryImp implements ChatRepository { + final ChatRemoteDataSource remoteDataSource; + + ChatRepositoryImp({required this.remoteDataSource}); + + @override + Stream sendMessages(List messages) { + return remoteDataSource.sendMessages(messages); + } +} diff --git a/lib/data/repositories/chat_session_repository_imp.dart b/lib/data/repositories/chat_session_repository_imp.dart new file mode 100644 index 0000000..9c4c615 --- /dev/null +++ b/lib/data/repositories/chat_session_repository_imp.dart @@ -0,0 +1,30 @@ +import '../../domain/entities/chat_session.dart'; +import '../../domain/repositories_abstract/chat_session_repository.dart'; + +class ChatSessionRepositoryImpl implements ChatSessionRepository { + @override + Future> getAllSessions() async { + // Fetch sessions from data source (e.g., local storage, API) + return []; + } + + @override + Future createSession(ChatSession session) async { + // Create session to a data source + } + + @override + Future saveSession(ChatSession session) async { + // Save session to a data source + } + + @override + Future deleteSession(String id) async { + // Delete session from data source + } + + @override + Future selectSession(String id) async { + return null; + } +} diff --git a/lib/domain/entities/chat_session.dart b/lib/domain/entities/chat_session.dart new file mode 100644 index 0000000..54ad1eb --- /dev/null +++ b/lib/domain/entities/chat_session.dart @@ -0,0 +1,33 @@ +import 'message.dart'; + +class ChatSession { + final String id; + final String title; + List messages; + + // Constructor + ChatSession({ + required this.id, + required this.title, + List? messages, // Optional parameter for messages + }) : messages = messages ?? []; // Initialize messages as an empty list if null + + factory ChatSession.fromJson(Map json) { + var messagesJson = json['messages'] as List; + List messages = messagesJson.map((msg) => Message.fromJson(msg)).toList(); + + return ChatSession( + id: json['id'], + title: json['title'], + messages: messages, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'messages': messages.map((msg) => (msg as Message).toJson()).toList(), + }; + } +} \ No newline at end of file diff --git a/lib/domain/entities/message.dart b/lib/domain/entities/message.dart new file mode 100644 index 0000000..510dd43 --- /dev/null +++ b/lib/domain/entities/message.dart @@ -0,0 +1,22 @@ +class Message { + final String role; + String content; + + Message({required this.role, required this.content}); + + // Factory constructor for creating a new Message instance from JSON + factory Message.fromJson(Map json) { + return Message( + role: json['role'], + content: json['content'], + ); + } + + // Method for converting a Message instance to JSON format + Map toJson() { + return { + 'role': role, + 'content': content, + }; + } +} \ No newline at end of file diff --git a/lib/domain/repositories_abstract/chat_repository.dart b/lib/domain/repositories_abstract/chat_repository.dart new file mode 100644 index 0000000..2bfdb46 --- /dev/null +++ b/lib/domain/repositories_abstract/chat_repository.dart @@ -0,0 +1,5 @@ +import '../entities/message.dart'; + +abstract class ChatRepository { + Stream sendMessages(List messages); +} \ No newline at end of file diff --git a/lib/domain/repositories_abstract/chat_session_repository.dart b/lib/domain/repositories_abstract/chat_session_repository.dart new file mode 100644 index 0000000..4977e1d --- /dev/null +++ b/lib/domain/repositories_abstract/chat_session_repository.dart @@ -0,0 +1,9 @@ +import '../entities/chat_session.dart'; + +abstract class ChatSessionRepository { + Future> getAllSessions(); // Fetch sessions from a data source + Future createSession(ChatSession session); + Future saveSession(ChatSession session); + Future deleteSession(String id); + Future selectSession(String id); +} diff --git a/lib/domain/usecases/chat_session_usecase.dart b/lib/domain/usecases/chat_session_usecase.dart new file mode 100644 index 0000000..95109a2 --- /dev/null +++ b/lib/domain/usecases/chat_session_usecase.dart @@ -0,0 +1,53 @@ +import '../entities/chat_session.dart'; +import '../entities/message.dart'; +import '../repositories_abstract/chat_session_repository.dart'; + +class ChatSessionUseCase { + final ChatSessionRepository repository; + + ChatSessionUseCase(this.repository); + + Future> getAllSessions() async { + return repository.getAllSessions(); + } + + Future saveSession(ChatSession session) async { + // You can add business rules here (e.g., session validation) + repository.saveSession(session); + } + + Future deleteSession(String sessionId) async { + repository.deleteSession(sessionId); + } + + @override + Future createSession(ChatSession session) async { + repository.createSession(session); + } + + void addMessageToSession(ChatSession session, Message message) { + session.messages.add(message); + saveSession(session); + } + + // Check if the last message in the session is from the assistant + bool isLastMessageAssistInSession(ChatSession session) { + final messages = session.messages; + if (messages != null && messages.isNotEmpty) { + return messages.last.role == 'assistant'; + } + return false; + } + + // Add a new method to update the last assistant message + void updateLastAssistantMessage(ChatSession session, String token) { + // Find the last message that is from the assistant + for (var i = session.messages.length - 1; i >= 0; i--) { + if (session.messages[i].role == 'assistant') { + final previousContent = session.messages[i].content; + session.messages[i] = Message(role: 'assistant', content: previousContent + token); + break; + } + } + } +} \ No newline at end of file diff --git a/lib/domain/usecases/chat_usecase.dart b/lib/domain/usecases/chat_usecase.dart new file mode 100644 index 0000000..d715477 --- /dev/null +++ b/lib/domain/usecases/chat_usecase.dart @@ -0,0 +1,12 @@ +import '../entities/message.dart'; +import '../repositories_abstract/chat_repository.dart'; + +class ChatUseCase { + final ChatRepository repository; + + ChatUseCase(this.repository); + + Stream sendMessages(List messages) { + return repository.sendMessages(messages); + } +} diff --git a/lib/home/Chat/chat_page.dart b/lib/home/Chat/chat_page.dart deleted file mode 100644 index 5a9e206..0000000 --- a/lib/home/Chat/chat_page.dart +++ /dev/null @@ -1,235 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:provider/provider.dart'; - -import 'chat_service.dart'; -import 'chat_session.dart'; -import 'chat_session_manager.dart'; -import 'markdown_with_math.dart'; - -class ChatPage extends StatefulWidget { - const ChatPage({Key? key}) : super(key: key); - - @override - State createState() => _ChatPageState(); -} - -class _ChatPageState extends State { - final TextEditingController _controller = TextEditingController(); - final ScrollController _scrollController = ScrollController(); - bool _isLoading = false; - - @override - void dispose() { - _controller.dispose(); - _scrollController.dispose(); - super.dispose(); - } - - @override - void initState() { - super.initState(); - - // Add a new session if there are no sessions - WidgetsBinding.instance.addPostFrameCallback((_) { - final chatSessionManager = - Provider.of(context, listen: false); - if (chatSessionManager.sessions.isEmpty) { - final newSession = ChatSession( - id: UniqueKey().toString(), - title: 'Session 1', - ); - chatSessionManager.addSession(newSession); - chatSessionManager.selectSession(newSession); - } - }); - } - - void _scrollToBottom() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.jumpTo(_scrollController.position.maxScrollExtent); - } - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text("Chat")), - drawer: Drawer( - child: Consumer( - builder: (context, chatSessionManager, child) { - return ListView( - padding: EdgeInsets.zero, - children: [ - DrawerHeader( - decoration: BoxDecoration( - color: Color.fromRGBO(51, 66, 78, 1), - ), - child: Text( - 'Chat Sessions', - style: TextStyle( - color: Colors.white, - fontSize: 24, - ), - ), - ), - ...chatSessionManager.sessions.map((session) { - return ListTile( - leading: Icon(Icons.chat), - title: Text(session.title), - onTap: () { - chatSessionManager - .selectSession(session); // Select the tapped session - _scrollToBottom(); - Navigator.pop(context); // Close the drawer - }, - ); - }).toList(), - ListTile( - leading: Icon(Icons.add), - title: Text('New Session'), - onTap: () { - final newSession = ChatSession( - id: UniqueKey().toString(), - title: - 'Session ${chatSessionManager.sessions.length + 1}', - ); - chatSessionManager.addSession(newSession); - chatSessionManager.selectSession(newSession); - Navigator.pop(context); // Close the drawer - }, - ), - ], - ); - }, - ), - ), - body: Consumer( - builder: (context, chatSessionManager, child) { - final session = chatSessionManager.selectedSession; - - if (session == null) { - return Center(child: Text('No session selected.')); - } - - return Column( - children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - itemCount: session.messages.length, - itemBuilder: (context, index) { - final message = session.messages[index]; - final isUserMessage = message['role'] == 'user'; - - if (isUserMessage) { - return Align( - alignment: Alignment.centerRight, - child: Container( - margin: EdgeInsets.symmetric( - vertical: 5.0, horizontal: 10.0), - padding: EdgeInsets.symmetric( - vertical: 10.0, horizontal: 15.0), - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(20.0), - ), - child: Text( - message['content'] ?? '', - style: TextStyle( - fontSize: 15, - color: Colors.black, - ), - ), - ), - ); - } else { - return Align( - alignment: Alignment.centerLeft, - child: Container( - padding: EdgeInsets.fromLTRB(15.0, 10, 15, 10), - child: MarkdownWithMath( - markdownData: message['content'] ?? ''))); - } - }, - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - decoration: InputDecoration( - hintText: 'Ask a question...', - ), - ), - ), - _isLoading - ? CircularProgressIndicator() // Show loading indicator - : IconButton( - icon: Icon(Icons.send), - onPressed: () async { - if (_controller.text.isNotEmpty) { - setState(() { - _isLoading = true; // Start loading - }); - - final chatService = ChatService(); - - chatSessionManager.addMessageToSession( - session.id, - 'user', - _controller.text, - ); - - _controller.clear(); - - _scrollToBottom(); - - // Add an empty assistant message - chatSessionManager.addMessageToSession( - session.id, - 'assistant', - '', - ); - - try { - String assistantResponse = ''; - await chatService.sendMessage(session).listen( - (word) { - assistantResponse += word; - chatSessionManager - .updateLastAssistantMessage( - session.id, - assistantResponse, - ); - _scrollToBottom(); // Scroll as words arrive - // print(assistantResponse); - }, onDone: () { - setState(() { - _isLoading = false; // Stop loading - }); - }); - } catch (error) { - setState(() { - _isLoading = false; // Stop loading - }); - } - } - }, - ), - ], - ), - ), - ], - ); - }, - ), - ); - } -} diff --git a/lib/home/Chat/chat_service.dart b/lib/home/Chat/chat_service.dart deleted file mode 100644 index 898e09f..0000000 --- a/lib/home/Chat/chat_service.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:swiftcomp/home/Chat/chat_session.dart'; -import 'dart:convert'; -import 'chat_config.dart'; - -class ChatService { - // ChatConfig is in .gitignore. You should use your own API key here. - final String _apiKey = ChatConfig.apiKey; - final String _apiUrl = 'https://api.openai.com/v1/chat/completions'; - - Stream sendMessage(ChatSession chatSession) async* { - final request = http.Request('POST', Uri.parse(_apiUrl)) - ..headers.addAll({ - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $_apiKey', - }) - ..body = jsonEncode({ - "model": "gpt-4o", - "stream": true, // Enable streaming - 'messages': chatSession.messages, - }); - - final streamedResponse = await request.send(); - - if (streamedResponse.statusCode == 200) { - final utf8DecodedStream = streamedResponse.stream.transform(utf8.decoder); - - // Buffer to accumulate each word - String buffer = ''; - - await for (var chunk in utf8DecodedStream) { - buffer += chunk; - - // Split the buffer by newlines - List lines = buffer.split('\n'); - - // Retain the last line in the buffer (it may be incomplete) - buffer = lines.removeLast(); - - for (var line in lines) { - // Only process lines starting with "data:" - if (line.startsWith('data:')) { - var jsonString = line.replaceFirst('data: ', '').trim(); - - if (jsonString == '[DONE]') { - // Close the stream if done - return; - } - - try { - final data = jsonDecode(jsonString); - - if (data['choices'] != null && data['choices'].isNotEmpty) { - var content = data['choices'][0]['delta']['content'] ?? ''; - // print(content); - yield content; - } - } catch (e) { - // Handle JSON parsing errors, likely due to incomplete data - continue; - } - } - } - } - - } else { - print(streamedResponse.statusCode); - throw Exception('Failed to communicate with ChatGPT'); - } - } -} diff --git a/lib/home/Chat/chat_session.dart b/lib/home/Chat/chat_session.dart deleted file mode 100644 index ebc43b7..0000000 --- a/lib/home/Chat/chat_session.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -class ChatSession { - final String id; - final String title; - List> messages; - - // Constructor - ChatSession({ - required this.id, - required this.title, - List>? messages, // Optional parameter for messages - }) : messages = messages ?? []; // Initialize messages as an empty list if null -} \ No newline at end of file diff --git a/lib/home/Chat/chat_session_manager.dart b/lib/home/Chat/chat_session_manager.dart deleted file mode 100644 index 8015e88..0000000 --- a/lib/home/Chat/chat_session_manager.dart +++ /dev/null @@ -1,42 +0,0 @@ - -import 'package:flutter/cupertino.dart'; - -import 'chat_session.dart'; - -class ChatSessionManager with ChangeNotifier { - List _sessions = []; - ChatSession? _selectedSession; - - List get sessions => _sessions; - ChatSession? get selectedSession => _selectedSession; - - void addSession(ChatSession session) { - _sessions.add(session); - notifyListeners(); - } - - void removeSession(String id) { - _sessions.removeWhere((session) => session.id == id); - notifyListeners(); - } - - void addMessageToSession(String id, String role, String content) { - final session = _sessions.firstWhere((session) => session.id == id); - session.messages.add({'role': role, 'content': content}); - notifyListeners(); - } - - // Add a new method to update the last assistant message - void updateLastAssistantMessage(String id, String content) { - final session = _sessions.firstWhere((session) => session.id == id); - // Find the last message that is from the assistant - final assistantMessage = session.messages.lastWhere((message) => message['role'] == 'assistant'); - assistantMessage['content'] = content; - notifyListeners(); - } - - void selectSession(ChatSession session) { - _selectedSession = session; - notifyListeners(); - } -} diff --git a/lib/home/Chat/viewModels/chat_view_model.dart b/lib/home/Chat/viewModels/chat_view_model.dart new file mode 100644 index 0000000..62fbe9a --- /dev/null +++ b/lib/home/Chat/viewModels/chat_view_model.dart @@ -0,0 +1,139 @@ +import 'package:flutter/cupertino.dart'; +import 'package:swiftcomp/domain/usecases/chat_session_usecase.dart'; +import 'package:swiftcomp/domain/usecases/chat_usecase.dart'; + +import '../../../domain/entities/chat_session.dart'; +import '../../../domain/entities/message.dart'; + +class ChatViewModel extends ChangeNotifier { + final ChatUseCase _chatUseCase; + final ChatSessionUseCase _chatSessionUseCase; + + final TextEditingController _controller = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + bool _isLoading = false; + List _sessions = []; + ChatSession? _selectedSession; + + TextEditingController get controller => _controller; + + ScrollController get scrollController => _scrollController; + + bool get isLoading => _isLoading; + + List get sessions => _sessions; + + ChatSession? get selectedSession => _selectedSession; + + ChatViewModel(this._chatUseCase, this._chatSessionUseCase) { + _controller.addListener(_onUserInputChanged); + } + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + // Initialize session if no sessions exist + void initializeSession() async { + _sessions = await _chatSessionUseCase.getAllSessions(); + if (_sessions.isEmpty) { + final newSession = ChatSession( + id: UniqueKey().toString(), + title: 'Session 1', + ); + _chatSessionUseCase.saveSession(newSession); + _sessions = [newSession]; + } + _selectedSession = _sessions.first; + notifyListeners(); + } + + void setLoading(bool value) { + _isLoading = value; + notifyListeners(); + } + + void scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + } + }); + } + + void addNewSession() { + final newSession = ChatSession( + id: UniqueKey().toString(), + title: 'Session ${sessions.length + 1}', + ); + _chatSessionUseCase.createSession(newSession); + _sessions.add(newSession); + selectSession(newSession); + } + + void selectSession(ChatSession session) { + _selectedSession = session; + notifyListeners(); + scrollToBottom(); + } + + Future sendMessage() async { + if (!isUserInputEmpty() && selectedSession != null) { + setLoading(true); + final userMessage = Message(role: 'user', content: _controller.text); + _chatSessionUseCase.addMessageToSession(selectedSession!, userMessage); + _controller.clear(); + scrollToBottom(); + notifyListeners(); + + try { + await _chatUseCase.sendMessages(selectedSession!.messages).listen( + (token) { + final bool isLastMessageAssist = _chatSessionUseCase + .isLastMessageAssistInSession(selectedSession!); + if (isLastMessageAssist) { + _chatSessionUseCase.updateLastAssistantMessage( + selectedSession!, token); + } else { + _chatSessionUseCase.addMessageToSession( + selectedSession!, + Message(role: "assistant", content: token), + ); + } + scrollToBottom(); + notifyListeners(); + }, onDone: () { + setLoading(false); + }); + } catch (error) { + setLoading(false); + } + } + } + + bool isUserMessage(Message message) { + return message.role == 'user'; + } + + String assistantMessageContent(Message message) { + final isLastMessage = selectedSession?.messages.last == message; + + final shouldAppendDot = _isLoading && isLastMessage; + + final assistantMessageContent = shouldAppendDot + ? message.content + " ●" + : message.content; + return assistantMessageContent; + } + + void _onUserInputChanged() { + notifyListeners(); + } + + bool isUserInputEmpty() { + return controller.text.isEmpty; + } +} diff --git a/lib/home/Chat/views/chat_message_list.dart b/lib/home/Chat/views/chat_message_list.dart new file mode 100644 index 0000000..3b0fa17 --- /dev/null +++ b/lib/home/Chat/views/chat_message_list.dart @@ -0,0 +1,94 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'markdown_with_math.dart'; +import '../viewModels/chat_view_model.dart'; + +class ChatMessageList extends StatelessWidget { + @override + Widget build(BuildContext context) { + final chatViewModel = Provider.of(context); + + final selectedSession = chatViewModel.selectedSession; + + if (selectedSession == null) { + return Center(child: Text('No session selected.')); + } + + return Column( + children: [ + Expanded( + child: ListView.builder( + controller: chatViewModel.scrollController, + itemCount: selectedSession.messages.length, + itemBuilder: (context, index) { + final message = selectedSession.messages[index]; + final isUserMessage = chatViewModel.isUserMessage(message); + + if (isUserMessage) { + return Align( + alignment: Alignment.centerRight, + child: Container( + margin: + EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0), + padding: + EdgeInsets.symmetric(vertical: 10.0, horizontal: 15.0), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(20.0), + ), + child: Text( + message.content, + style: TextStyle( + fontSize: 15, + color: Colors.black, + ), + ), + ), + ); + } else { + final assistantMessageContent = + chatViewModel.assistantMessageContent(message); + return Align( + alignment: Alignment.centerLeft, + child: Container( + padding: + EdgeInsets.symmetric(vertical: 10.0, horizontal: 15.0), + child: + MarkdownWithMath(markdownData: assistantMessageContent), + ), + ); + } + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: chatViewModel.controller, + decoration: InputDecoration( + hintText: 'Ask a question...', + ), + ), + ), + chatViewModel.isLoading + ? CircularProgressIndicator() // Show loading indicator + : IconButton( + icon: Icon(Icons.send), + onPressed: chatViewModel.isUserInputEmpty() + ? null + : () async { + await chatViewModel.sendMessage(); + }, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/home/Chat/views/chat_screen.dart b/lib/home/Chat/views/chat_screen.dart new file mode 100644 index 0000000..88d8bf0 --- /dev/null +++ b/lib/home/Chat/views/chat_screen.dart @@ -0,0 +1,35 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../injection_container.dart'; +import '../viewModels/chat_view_model.dart'; +import 'chat_message_list.dart'; +import 'chat_session_drawer.dart'; + +class ChatScreen extends StatefulWidget { + const ChatScreen({Key? key}) : super(key: key); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) { + final viewModel = sl(); + WidgetsBinding.instance.addPostFrameCallback((_) { + viewModel.initializeSession(); + }); + return viewModel; + }, + child: Scaffold( + appBar: AppBar(title: const Text("Chat")), + drawer: ChatDrawer(), + body: ChatMessageList(), + ), + ); + } +} diff --git a/lib/home/Chat/views/chat_session_drawer.dart b/lib/home/Chat/views/chat_session_drawer.dart new file mode 100644 index 0000000..9b3511b --- /dev/null +++ b/lib/home/Chat/views/chat_session_drawer.dart @@ -0,0 +1,51 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../domain/entities/chat_session.dart'; +import '../viewModels/chat_view_model.dart'; + +class ChatDrawer extends StatelessWidget { + @override + Widget build(BuildContext context) { + final chatViewModel = Provider.of(context); + + return Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + DrawerHeader( + decoration: BoxDecoration( + color: Color.fromRGBO(51, 66, 78, 1), + ), + child: Text( + 'Chat Sessions', + style: TextStyle( + color: Colors.white, + fontSize: 24, + ), + ), + ), + ...chatViewModel.sessions.map((session) { + return ListTile( + leading: Icon(Icons.chat), + title: Text(session.title), + onTap: () { + chatViewModel.selectSession(session); + Navigator.pop(context); // Close the drawer after selecting a session + }, + ); + }).toList(), + ListTile( + leading: Icon(Icons.add), + title: Text('New Session'), + onTap: () { + chatViewModel.addNewSession(); + Navigator.pop(context); // Close the drawer after creating a new session + }, + ), + ], + ), + ); + } +} diff --git a/lib/home/Chat/markdown_with_math.dart b/lib/home/Chat/views/markdown_with_math.dart similarity index 100% rename from lib/home/Chat/markdown_with_math.dart rename to lib/home/Chat/views/markdown_with_math.dart diff --git a/lib/home/bottom_navigator.dart b/lib/home/bottom_navigator.dart index 81c29d3..9cd6813 100644 --- a/lib/home/bottom_navigator.dart +++ b/lib/home/bottom_navigator.dart @@ -6,7 +6,7 @@ import 'package:provider/provider.dart'; import 'package:swiftcomp/home/more/feature_flag_provider.dart'; import 'package:swiftcomp/home/tools/page/tool_page.dart'; -import 'Chat/chat_page.dart'; +import 'chat/views/chat_screen.dart'; import 'more/more_page.dart'; class BottomNavigator extends StatefulWidget { @@ -42,7 +42,7 @@ class _BottomNavigatorState extends State { body: PageView( controller: _controller, physics: const NeverScrollableScrollPhysics(), - children: [ToolPage(), if (isChatEnabled) ChatPage(), MorePage()], + children: [ToolPage(), if (isChatEnabled) ChatScreen(), MorePage()], ), bottomNavigationBar: BottomNavigationBar( backgroundColor: Color.fromRGBO(51, 66, 78, 1), diff --git a/lib/injection_container.dart b/lib/injection_container.dart new file mode 100644 index 0000000..eb3499c --- /dev/null +++ b/lib/injection_container.dart @@ -0,0 +1,35 @@ +import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; +import 'package:swiftcomp/data/repositories/chat_session_repository_imp.dart'; +import 'package:swiftcomp/domain/repositories_abstract/chat_session_repository.dart'; +import 'package:swiftcomp/domain/usecases/chat_session_usecase.dart'; +import 'package:swiftcomp/domain/usecases/chat_usecase.dart'; +import 'data/data_sources/chat_remote_data_source.dart'; +import 'data/repositories/chat_repository_impl.dart'; +import 'domain/repositories_abstract/chat_repository.dart'; +import 'home/chat/viewModels/chat_view_model.dart'; + +final sl = GetIt.instance; + +void initInjection() { + // ViewModels + sl.registerFactory(() => ChatViewModel(sl(), sl())); + + // Use Cases + sl.registerLazySingleton(() => ChatUseCase(sl())); + + sl.registerLazySingleton(() => ChatSessionUseCase(sl())); + + // Repositories + sl.registerLazySingleton( + () => ChatRepositoryImp(remoteDataSource: sl())); + sl.registerLazySingleton( + () => ChatSessionRepositoryImpl()); + + // Data Sources + sl.registerLazySingleton( + () => ChatRemoteDataSourceImpl(client: sl())); + + // External + sl.registerLazySingleton(() => http.Client()); +} diff --git a/lib/main.dart b/lib/main.dart index 1f06983..f310e25 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,10 +12,8 @@ import 'package:swiftcomp/util/others.dart'; import 'package:app_tracking_transparency/app_tracking_transparency.dart'; import 'amplifyconfiguration.dart'; -import 'home/Chat/chat_session.dart'; -import 'home/Chat/chat_session_manager.dart'; import 'home/bottom_navigator.dart'; -import 'home/tools/page/tool_page.dart'; +import 'injection_container.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -28,6 +26,7 @@ void main() async { await SharedPreferencesHelper.init(); + initInjection(); runApp(MyApp()); } @@ -64,7 +63,6 @@ class _MyAppState extends State { providers: [ ChangeNotifierProvider(create: (context) => NumberPrecisionHelper()), ChangeNotifierProvider(create: (context) => FeatureFlagProvider()), - ChangeNotifierProvider(create: (context) => ChatSessionManager()) ], child: MaterialApp( debugShowCheckedModeBanner: false, diff --git a/pubspec.lock b/pubspec.lock index b4503fc..67bf71d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -405,6 +405,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.5" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: ff97e5e7b2e82e63c82f5658c6ba2605ea831f0f7489b0d2fb255d817ec4eb5e + url: "https://pub.dev" + source: hosted + version: "8.0.0" glob: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fbfb745..6d14d7e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: flutter_progress_hud: ^2.0.2 file: ^6.1.4 flutter_markdown: ^0.7.3+1 + get_it: ^8.0.0 amplify_flutter: ^1.8.0 amplify_auth_cognito: ^1.8.0