diff --git a/ChatBot/ChatBot.xcodeproj/project.pbxproj b/ChatBot/ChatBot.xcodeproj/project.pbxproj index 74bc8812..1a10aa10 100644 --- a/ChatBot/ChatBot.xcodeproj/project.pbxproj +++ b/ChatBot/ChatBot.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 2770189E2BCBF4A20040A6A9 /* UIViewController+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2770189D2BCBF4A20040A6A9 /* UIViewController+.swift */; }; + 27E09B7B2BD4F1A000C37C48 /* ChattingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E09B7A2BD4F1A000C37C48 /* ChattingView.swift */; }; + 27E09B7D2BD50DD700C37C48 /* ChatBubbleMakable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E09B7C2BD50DD700C37C48 /* ChatBubbleMakable.swift */; }; 27EFEA4F2BBC174D004A3426 /* ChatBotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFEA4E2BBC174D004A3426 /* ChatBotTests.swift */; }; 27EFEA562BBD3EDB004A3426 /* ChatBotNetworkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFEA552BBD3EDB004A3426 /* ChatBotNetworkTests.swift */; }; 27EFEA612BBD6D75004A3426 /* Bundle+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFEA602BBD6D75004A3426 /* Bundle+.swift */; }; @@ -22,6 +25,14 @@ 27EFEA7B2BBD7DC3004A3426 /* MockURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFEA7A2BBD7DC3004A3426 /* MockURLSession.swift */; }; 27EFEA7D2BBD7DFD004A3426 /* NetworkTestable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFEA7C2BBD7DFC004A3426 /* NetworkTestable.swift */; }; 27EFEA7F2BBE44AA004A3426 /* NetworkURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFEA7E2BBE44AA004A3426 /* NetworkURL.swift */; }; + 27EFEA832BBFD813004A3426 /* ChatCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFEA822BBFD813004A3426 /* ChatCollectionView.swift */; }; + 27EFEA852BBFD836004A3426 /* ChatCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFEA842BBFD836004A3426 /* ChatCell.swift */; }; + 27EFEA872BBFDD0F004A3426 /* ChatCollectionViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27EFEA862BBFDD0F004A3426 /* ChatCollectionViewDataSource.swift */; }; + 893621222BC4014D00EFB2C7 /* ChatInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 893621212BC4014D00EFB2C7 /* ChatInputView.swift */; }; + 893621242BC4017F00EFB2C7 /* ChatInputTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 893621232BC4017F00EFB2C7 /* ChatInputTextView.swift */; }; + 893621262BC40E5500EFB2C7 /* ChatInputSendButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 893621252BC40E5500EFB2C7 /* ChatInputSendButton.swift */; }; + 89510A662BBE938700354947 /* ChatBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89510A652BBE938700354947 /* ChatBubbleView.swift */; }; + 89510A682BBE9BC400354947 /* MessageLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89510A672BBE9BC400354947 /* MessageLabel.swift */; }; 89A49A642BB4233C00C643D3 /* ResponseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A49A632BB4233C00C643D3 /* ResponseModel.swift */; }; 89A49A662BB426ED00C643D3 /* RequestModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A49A652BB426ED00C643D3 /* RequestModel.swift */; }; 89A49A682BB4272600C643D3 /* Choice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A49A672BB4272600C643D3 /* Choice.swift */; }; @@ -45,6 +56,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 2770189D2BCBF4A20040A6A9 /* UIViewController+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+.swift"; sourceTree = ""; }; + 27E09B7A2BD4F1A000C37C48 /* ChattingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChattingView.swift; sourceTree = ""; }; + 27E09B7C2BD50DD700C37C48 /* ChatBubbleMakable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleMakable.swift; sourceTree = ""; }; 27EFEA4C2BBC174D004A3426 /* ChatBotTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ChatBotTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 27EFEA4E2BBC174D004A3426 /* ChatBotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBotTests.swift; sourceTree = ""; }; 27EFEA552BBD3EDB004A3426 /* ChatBotNetworkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBotNetworkTests.swift; sourceTree = ""; }; @@ -62,6 +76,14 @@ 27EFEA7A2BBD7DC3004A3426 /* MockURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLSession.swift; sourceTree = ""; }; 27EFEA7C2BBD7DFC004A3426 /* NetworkTestable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkTestable.swift; sourceTree = ""; }; 27EFEA7E2BBE44AA004A3426 /* NetworkURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkURL.swift; sourceTree = ""; }; + 27EFEA822BBFD813004A3426 /* ChatCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatCollectionView.swift; sourceTree = ""; }; + 27EFEA842BBFD836004A3426 /* ChatCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatCell.swift; sourceTree = ""; }; + 27EFEA862BBFDD0F004A3426 /* ChatCollectionViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatCollectionViewDataSource.swift; sourceTree = ""; }; + 893621212BC4014D00EFB2C7 /* ChatInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputView.swift; sourceTree = ""; }; + 893621232BC4017F00EFB2C7 /* ChatInputTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputTextView.swift; sourceTree = ""; }; + 893621252BC40E5500EFB2C7 /* ChatInputSendButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputSendButton.swift; sourceTree = ""; }; + 89510A652BBE938700354947 /* ChatBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleView.swift; sourceTree = ""; }; + 89510A672BBE9BC400354947 /* MessageLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageLabel.swift; sourceTree = ""; }; 89A49A632BB4233C00C643D3 /* ResponseModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseModel.swift; sourceTree = ""; }; 89A49A652BB426ED00C643D3 /* RequestModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestModel.swift; sourceTree = ""; }; 89A49A672BB4272600C643D3 /* Choice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Choice.swift; sourceTree = ""; }; @@ -135,6 +157,7 @@ children = ( 27EFEA602BBD6D75004A3426 /* Bundle+.swift */, 27EFEA6B2BBD7B32004A3426 /* URLSession+.swift */, + 2770189D2BCBF4A20040A6A9 /* UIViewController+.swift */, ); path = Extensions; sourceTree = ""; @@ -180,6 +203,23 @@ path = Mock; sourceTree = ""; }; + 89510A642BBE934E00354947 /* View */ = { + isa = PBXGroup; + children = ( + 89510A652BBE938700354947 /* ChatBubbleView.swift */, + 27E09B7C2BD50DD700C37C48 /* ChatBubbleMakable.swift */, + 89510A672BBE9BC400354947 /* MessageLabel.swift */, + 27EFEA822BBFD813004A3426 /* ChatCollectionView.swift */, + 27EFEA842BBFD836004A3426 /* ChatCell.swift */, + 27EFEA862BBFDD0F004A3426 /* ChatCollectionViewDataSource.swift */, + 893621212BC4014D00EFB2C7 /* ChatInputView.swift */, + 893621252BC40E5500EFB2C7 /* ChatInputSendButton.swift */, + 893621232BC4017F00EFB2C7 /* ChatInputTextView.swift */, + 27E09B7A2BD4F1A000C37C48 /* ChattingView.swift */, + ); + path = View; + sourceTree = ""; + }; 89A49A622BB422DF00C643D3 /* Model */ = { isa = PBXGroup; children = ( @@ -232,6 +272,7 @@ isa = PBXGroup; children = ( 273615912BB2A496004C3F15 /* App */, + 89510A642BBE934E00354947 /* View */, 89A49A622BB422DF00C643D3 /* Model */, 27EFEA732BBD7BB0004A3426 /* ViewModel */, 273615922BB2A4A7004C3F15 /* Controller */, @@ -356,18 +397,29 @@ 27EFEA6C2BBD7B32004A3426 /* URLSession+.swift in Sources */, 27EFEA792BBD7DA1004A3426 /* MockDataSample.swift in Sources */, 27EFEA7F2BBE44AA004A3426 /* NetworkURL.swift in Sources */, + 27E09B7D2BD50DD700C37C48 /* ChatBubbleMakable.swift in Sources */, + 893621222BC4014D00EFB2C7 /* ChatInputView.swift in Sources */, B4B3E2C12B42D1BB00818B3C /* ChatBotViewController.swift in Sources */, + 89510A682BBE9BC400354947 /* MessageLabel.swift in Sources */, 27EFEA7D2BBD7DFD004A3426 /* NetworkTestable.swift in Sources */, + 27EFEA872BBFDD0F004A3426 /* ChatCollectionViewDataSource.swift in Sources */, + 27EFEA832BBFD813004A3426 /* ChatCollectionView.swift in Sources */, B4B3E2BD2B42D1BB00818B3C /* AppDelegate.swift in Sources */, 27EFEA7B2BBD7DC3004A3426 /* MockURLSession.swift in Sources */, 27EFEA6A2BBD7B17004A3426 /* HttpMethod.swift in Sources */, 27EFEA702BBD7B7D004A3426 /* JSONHandler.swift in Sources */, 27EFEA682BBD7AF3004A3426 /* APIType.swift in Sources */, 89A49A6A2BB4273700C643D3 /* Usage.swift in Sources */, + 89510A662BBE938700354947 /* ChatBubbleView.swift in Sources */, 27EFEA722BBD7B95004A3426 /* NetworkError.swift in Sources */, + 27EFEA852BBFD836004A3426 /* ChatCell.swift in Sources */, 27EFEA752BBD7BC0004A3426 /* ChatViewModel.swift in Sources */, + 893621262BC40E5500EFB2C7 /* ChatInputSendButton.swift in Sources */, 89A49A6C2BB4274B00C643D3 /* Message.swift in Sources */, 27EFEA612BBD6D75004A3426 /* Bundle+.swift in Sources */, + 2770189E2BCBF4A20040A6A9 /* UIViewController+.swift in Sources */, + 893621242BC4017F00EFB2C7 /* ChatInputTextView.swift in Sources */, + 27E09B7B2BD4F1A000C37C48 /* ChattingView.swift in Sources */, 89A49A642BB4233C00C643D3 /* ResponseModel.swift in Sources */, 27EFEA6E2BBD7B53004A3426 /* NetworkService.swift in Sources */, B4B3E2BF2B42D1BB00818B3C /* SceneDelegate.swift in Sources */, @@ -560,7 +612,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = K9NKZR257N; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ChatBot/Supportings/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -573,7 +625,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.tacocat.ChatBot; + PRODUCT_BUNDLE_IDENTIFIER = com.tacocat.ChatBot.test; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -588,7 +640,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = K9NKZR257N; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ChatBot/Supportings/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -601,7 +653,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.tacocat.ChatBot; + PRODUCT_BUNDLE_IDENTIFIER = com.tacocat.ChatBot.test; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/ChatBot/ChatBot/Controller/ChatBotViewController.swift b/ChatBot/ChatBot/Controller/ChatBotViewController.swift index 15a8455a..ca9cc77f 100644 --- a/ChatBot/ChatBot/Controller/ChatBotViewController.swift +++ b/ChatBot/ChatBot/Controller/ChatBotViewController.swift @@ -11,40 +11,44 @@ import Combine final class ChatBotViewController: UIViewController { private let chatBotViewModel: ChatViewModel = .init() - private var cancellable = Set() + private let chattingView: ChattingView = .init() + private var cancellable: Set = .init() private let input: PassthroughSubject = .init() - override func viewDidLoad() { - super.viewDidLoad() + override func loadView() { + super.loadView() + self.view = chattingView + setupKeyboardNotification() bind() + setupChatInputView() } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - input.send( - .sendButtonTapped( - message: Message( - role: "user", - content: "Compose a poem that explains the concept of recursion in programming." - ) - ) - ) + + deinit { + NotificationCenter.default.removeObserver(self) } } private extension ChatBotViewController { + func setupChatInputView() { + chattingView.setChatSendButton { [weak self] message in + self?.input.send(.sendButtonTapped(message: message)) + guard let requestDTO = self?.chatBotViewModel.requestDTO else { return } + self?.chattingView.applyChatResponse(response: requestDTO) + } + } + func bind() { let output = chatBotViewModel.transform( input: input.eraseToAnyPublisher() ) - output.sink { event in + output.sink { [weak self] event in switch event { case .fetchChatResponseDidFail(let error): print(error.localizedDescription) case .fetchChatResponseDidSucceed(let response): - print(response.choices[0].message.content) + self?.chattingView.applyChatResponse(response: response) case .toggleSendButton(let isEnable): - print("\(isEnable)") + self?.chattingView.isSendButtonEnable(isEnable) } } .store(in: &cancellable) diff --git a/ChatBot/ChatBot/Extensions/UIViewController+.swift b/ChatBot/ChatBot/Extensions/UIViewController+.swift new file mode 100644 index 00000000..dd59faca --- /dev/null +++ b/ChatBot/ChatBot/Extensions/UIViewController+.swift @@ -0,0 +1,61 @@ +// +// UIViewController+.swift +// ChatBot +// +// Created by 강창현 on 4/14/24. +// + +import UIKit + +extension UIViewController { + func setupKeyboardNotification() { + NotificationCenter.default.addObserver( + self, + selector: #selector(self.keyboardWillShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.keyboardWillHide), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + addKeyboardGesture() + } + + private func addKeyboardGesture() { + let tapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(dismissKeyboard) + ) + view.addGestureRecognizer(tapGestureRecognizer) + } + + @objc + private func dismissKeyboard() { + view.endEditing(true) + } + + @objc + private func keyboardWillShow(notification: NSNotification) { + guard + let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue + else { + return + } + + self.view.frame.size.height -= keyboardSize.height + } + + @objc + private func keyboardWillHide(notification: NSNotification) { + guard + let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue + else { + return + } + + self.view.frame.size.height += keyboardSize.height + } +} diff --git a/ChatBot/ChatBot/Model/Message.swift b/ChatBot/ChatBot/Model/Message.swift index 811ea13c..4d0b5b97 100644 --- a/ChatBot/ChatBot/Model/Message.swift +++ b/ChatBot/ChatBot/Model/Message.swift @@ -5,7 +5,7 @@ // Created by 이보한 on 2024/3/27. // -struct Message: Codable { +struct Message: Codable, Hashable { let role: String let content: String } diff --git a/ChatBot/ChatBot/Model/Network/Mock/MockDataSample.swift b/ChatBot/ChatBot/Model/Network/Mock/MockDataSample.swift index 26fad268..d030d855 100644 --- a/ChatBot/ChatBot/Model/Network/Mock/MockDataSample.swift +++ b/ChatBot/ChatBot/Model/Network/Mock/MockDataSample.swift @@ -17,28 +17,28 @@ enum MockDataSample { content: "Compose a poem that explains the concept of recursion in programming." ), Message( - role: "system", - content: "You are a poetic assistant, skilled in explaining complex programming concepts with creative flair." + role: "assistant", + content: "$s7Combine9PublisherPAAE4sink17receiveCompletion0D5ValueAA14AnyCancellableCyAA11SubscribersO0E0Oy_7FailureQzGc_y6OutputQzctF + 304" ), Message( role: "user", - content: "Compose a poem that explains the concept of recursion in programming." + content: "$s7ChatBot0A9ViewModelC05fetchaB4Data33_C3094975EA75902AF323554274754D0BLL4bodyyAA07RequestD0V_tF + 548" ), Message( - role: "system", - content: "You are a poetic assistant, skilled in explaining complex programming concepts with creative flair." + role: "assistant", + content: "Y" ), Message( role: "user", - content: "Compose a poem that explains the concept of recursion in programming." + content: "ChatBot0A9ViewModelC9transform5input7Combine12AnyPublisherVyAC6OutputOs5NeverOGAHyAC5InputOALG_tFyAOcfU_ + 832" ), Message( - role: "system", - content: "You are a poetic assistant, skilled in explaining complex programming concepts with creative flair." + role: "assistant", + content: "Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Fatal: supplied item identifiers are not unique. Duplicate identifiers" ), Message( role: "user", - content: "Compose a poem that explains the concept of recursion in programming." + content: "17.4asdfasdfasdfasdfasdf" ), ] ) diff --git a/ChatBot/ChatBot/Model/Network/NetworkService.swift b/ChatBot/ChatBot/Model/Network/NetworkService.swift index 018cf43a..b2f8c15a 100644 --- a/ChatBot/ChatBot/Model/Network/NetworkService.swift +++ b/ChatBot/ChatBot/Model/Network/NetworkService.swift @@ -12,7 +12,7 @@ struct NetworkService: NetworkTestable { private let session: URLSessionProtocol - init(session: URLSessionProtocol = MockURLSession(statusCode: 200)) { + init(session: URLSessionProtocol = URLSession.shared) { self.session = session } @@ -29,3 +29,4 @@ struct NetworkService: NetworkTestable { .eraseToAnyPublisher() } } + diff --git a/ChatBot/ChatBot/Model/Request/RequestModel.swift b/ChatBot/ChatBot/Model/Request/RequestModel.swift index 8a362cc5..b0e8fb35 100644 --- a/ChatBot/ChatBot/Model/Request/RequestModel.swift +++ b/ChatBot/ChatBot/Model/Request/RequestModel.swift @@ -5,8 +5,15 @@ // Created by 이보한 on 2024/3/27. // +import Foundation + struct RequestModel: Codable { var model: String = "gpt-3.5-turbo-1106" var stream: Bool = false var messages: [Message] } + +struct RequestDTO: Hashable { + let id: UUID + var message: Message +} diff --git a/ChatBot/ChatBot/View/ChatBubbleMakable.swift b/ChatBot/ChatBot/View/ChatBubbleMakable.swift new file mode 100644 index 00000000..b2f1aa9c --- /dev/null +++ b/ChatBot/ChatBot/View/ChatBubbleMakable.swift @@ -0,0 +1,155 @@ +// +// ChatBubbleMakable.swift +// ChatBot +// +// Created by 강창현 on 4/21/24. +// + +import UIKit + +protocol ChatBubbleMakable where Self: UIView { + var messageLabel: MessageLabel { get } + func configureUI() + func setupConstraints() + func setRightBubbleView(rect: CGRect) + func setLeftBubbleView(rect: CGRect) + func configureMessage(text: String) +} + +extension ChatBubbleMakable { + func configureUI() { + self.addSubview(messageLabel) + messageLabel.translatesAutoresizingMaskIntoConstraints = false + self.translatesAutoresizingMaskIntoConstraints = false + } + + func setupConstraints() { + NSLayoutConstraint.activate( + [ + self.heightAnchor.constraint(equalTo: messageLabel.heightAnchor, constant: 15), + messageLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), + messageLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 10), + messageLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -10), + ] + ) + } + + func configureMessage(text: String) { + messageLabel.text = text + } + + func setRightBubbleView(rect: CGRect) { + let path = UIBezierPath() + messageLabel.textColor = .white + let bottom = rect.height + let right = rect.width + path.move( + to: CGPoint(x: right - 22, y: bottom) + ) + path.addLine( + to: CGPoint(x: 17, y: bottom) + ) + path.addCurve( + to: CGPoint(x: 0, y: bottom - 18), + controlPoint1: CGPoint(x: 7.61, y: bottom), + controlPoint2: CGPoint(x: 0, y: bottom - 7.61) + ) + path.addLine( + to: CGPoint(x: 0, y: 17) + ) + path.addCurve( + to: CGPoint(x: 17, y: 0), + controlPoint1: CGPoint(x: 0, y: 7.61), + controlPoint2: CGPoint(x: 7.61, y: 0) + ) + path.addLine( + to: CGPoint(x: right - 21, y: 0) + ) + path.addCurve( + to: CGPoint(x: right - 4, y: 17), + controlPoint1: CGPoint(x: right - 11.61, y: 0), + controlPoint2: CGPoint(x: right - 4, y: 7.61) + ) + path.addLine( + to: CGPoint(x: right - 4, y: bottom - 11) + ) + path.addCurve( + to: CGPoint(x: right, y: bottom), + controlPoint1: CGPoint(x: right - 4, y: bottom - 1), + controlPoint2: CGPoint(x: right, y: bottom) + ) + path.addLine( + to: CGPoint(x: right + 0.05, y: bottom - 0.01) + ) + path.addCurve( + to: CGPoint(x: right - 11.04, y: bottom - 4.04), + controlPoint1: CGPoint(x: right - 4.07, y: bottom + 0.43), + controlPoint2: CGPoint(x: right - 8.16, y: bottom - 1.06) + ) + path.addCurve( + to: CGPoint(x: right - 22, y: bottom), + controlPoint1: CGPoint(x: right - 16, y: bottom), + controlPoint2: CGPoint(x: right - 19, y: bottom) + ) + path.close() + UIColor(cgColor: UIColor.systemBlue.cgColor).setFill() + path.fill() + } + + func setLeftBubbleView(rect: CGRect) { + let path = UIBezierPath() + let bottom = rect.height + let right = rect.width + path.move( + to: CGPoint(x: 22, y: bottom) + ) + path.addLine( + to: CGPoint(x: right - 17, y: bottom) + ) + path.addCurve( + to: CGPoint(x: right, y: bottom - 18), + controlPoint1: CGPoint(x: right - 7.61, y: bottom), + controlPoint2: CGPoint(x: right, y: bottom - 7.61) + ) + path.addLine( + to: CGPoint(x: right, y: 17) + ) + path.addCurve( + to: CGPoint(x: right - 17, y: 0), + controlPoint1: CGPoint(x: right, y: 7.61), + controlPoint2: CGPoint(x: right - 7.61, y: 0) + ) + path.addLine( + to: CGPoint(x: 21, y: 0) + ) + path.addCurve( + to: CGPoint(x: 4, y: 17), + controlPoint1: CGPoint(x: 11.61, y: 0), + controlPoint2: CGPoint(x: 4, y: 7.61) + ) + path.addLine( + to: CGPoint(x: 4, y: bottom - 11) + ) + path.addCurve( + to: CGPoint(x: 0, y: bottom), + controlPoint1: CGPoint(x: 4, y: bottom - 1), + controlPoint2: CGPoint(x: 0, y: bottom) + ) + path.addLine( + to: CGPoint(x: -0.05, y: bottom - 0.01) + ) + path.addCurve( + to: CGPoint(x: 11.04, y: bottom - 4.04), + controlPoint1: CGPoint(x: 4.07, y: bottom + 0.43), + controlPoint2: CGPoint(x: 8.16, y: bottom - 1.06) + ) + path.addCurve( + to: CGPoint(x: 22, y: bottom), + controlPoint1: CGPoint(x: 16, y: bottom), + controlPoint2: CGPoint(x: 19, y: bottom) + ) + path.close() + UIColor(cgColor: UIColor.systemGray5.cgColor).setFill() + path.fill() + } +} diff --git a/ChatBot/ChatBot/View/ChatBubbleView.swift b/ChatBot/ChatBot/View/ChatBubbleView.swift new file mode 100644 index 00000000..1bfa756b --- /dev/null +++ b/ChatBot/ChatBot/View/ChatBubbleView.swift @@ -0,0 +1,51 @@ +// +// ChatBubbleView.swift +// Swift_CoreGraphics +// +// Created by 강창현 on 4/4/24. +// + +import UIKit + +final class UserBubbleView: UIView { + var messageLabel: MessageLabel = .init() + + override init(frame: CGRect) { + super.init(frame: frame) + contentMode = .redraw + self.backgroundColor = .clear + configureUI() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func draw(_ rect: CGRect) { + setRightBubbleView(rect: rect) + } +} + +extension UserBubbleView: ChatBubbleMakable { } + +final class SystemBubbleView: UIView { + var messageLabel: MessageLabel = .init() + + override init(frame: CGRect) { + super.init(frame: frame) + contentMode = .redraw + self.backgroundColor = .clear + self.configureUI() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + setLeftBubbleView(rect: rect) + } +} + +extension SystemBubbleView: ChatBubbleMakable { } diff --git a/ChatBot/ChatBot/View/ChatCell.swift b/ChatBot/ChatBot/View/ChatCell.swift new file mode 100644 index 00000000..18297ea0 --- /dev/null +++ b/ChatBot/ChatBot/View/ChatCell.swift @@ -0,0 +1,80 @@ +// +// ChatCell.swift +// ChatBot +// +// Created by 강창현 on 4/5/24. +// + +import UIKit + +final class ChatCell: UICollectionViewListCell { + static var identifier: String { + return String(describing: self) + } + + private var userBubbleView = UserBubbleView() + private var systemBubbleView = SystemBubbleView() + + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + setupUserConstraints() + setupSystemConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + configureUser(text: "") + configureSystem(text: "") + } + + func configureUser(text: String) { + systemBubbleView.isHidden = true + userBubbleView.isHidden = false + userBubbleView.configureMessage(text: text) + } + + func configureSystem(text: String) { + systemBubbleView.isHidden = false + userBubbleView.isHidden = true + systemBubbleView.configureMessage(text: text) + } +} + +private extension ChatCell { + func configureUI() { + systemBubbleView.translatesAutoresizingMaskIntoConstraints = false + userBubbleView.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(userBubbleView) + self.addSubview(systemBubbleView) + } + + func setupUserConstraints() { + NSLayoutConstraint.activate( + [ + userBubbleView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -10), + userBubbleView.topAnchor.constraint(equalTo: self.topAnchor, constant: 5), + userBubbleView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -5), + userBubbleView.widthAnchor.constraint(lessThanOrEqualToConstant: UIScreen.main.bounds.width * 0.75), + userBubbleView.widthAnchor.constraint(greaterThanOrEqualToConstant: UIScreen.main.bounds.width * 0.1), + userBubbleView.heightAnchor.constraint(greaterThanOrEqualToConstant: UIScreen.main.bounds.width * 0.1), + ] + ) + } + + func setupSystemConstraints() { + NSLayoutConstraint.activate( + [ + systemBubbleView.widthAnchor.constraint(lessThanOrEqualToConstant: UIScreen.main.bounds.width * 0.75), + systemBubbleView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 10), + systemBubbleView.topAnchor.constraint(equalTo: self.topAnchor, constant: 5), + systemBubbleView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -5), + systemBubbleView.widthAnchor.constraint(greaterThanOrEqualToConstant: UIScreen.main.bounds.width * 0.1), + ] + ) + } +} diff --git a/ChatBot/ChatBot/View/ChatCollectionView.swift b/ChatBot/ChatBot/View/ChatCollectionView.swift new file mode 100644 index 00000000..6e77fe5a --- /dev/null +++ b/ChatBot/ChatBot/View/ChatCollectionView.swift @@ -0,0 +1,37 @@ +// +// ChatCollectionView.swift +// ChatBot +// +// Created by 강창현 on 4/5/24. +// + +import UIKit + +final class ChatCollectionView: UICollectionView { + + override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { + super.init(frame: frame, collectionViewLayout: layout) + setupChatCell() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func scrollToBottom() { + DispatchQueue.main.async { + guard self.numberOfSections > 0 else { return } + let indexPath = IndexPath( + item: self.numberOfItems(inSection: self.numberOfSections - 1) - 1, + section: self.numberOfSections - 1 + ) + self.scrollToItem(at: indexPath, at: .bottom, animated: true) + } + } +} + +private extension ChatCollectionView { + func setupChatCell() { + self.register(ChatCell.self, forCellWithReuseIdentifier: ChatCell.identifier) + } +} diff --git a/ChatBot/ChatBot/View/ChatCollectionViewDataSource.swift b/ChatBot/ChatBot/View/ChatCollectionViewDataSource.swift new file mode 100644 index 00000000..8e3f1adb --- /dev/null +++ b/ChatBot/ChatBot/View/ChatCollectionViewDataSource.swift @@ -0,0 +1,39 @@ +// +// ChatCollectionViewDataSource.swift +// ChatBot +// +// Created by 강창현 on 4/5/24. +// + +import UIKit + +enum MessageSection { + case messages +} + +typealias ChatCollectionViewSnapshot = NSDiffableDataSourceSnapshot + +final class ChatCollectionViewDataSource: UICollectionViewDiffableDataSource { + static let cellProvider: CellProvider = { collectionView, indexPath, model in + guard + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: ChatCell.identifier, + for: indexPath + ) as? ChatCell + else { + return ChatCell() + } + let message = model.message + guard message.role != "user" else { + cell.configureUser(text: message.content) + return cell + } + + cell.configureSystem(text: message.content) + return cell + } + + convenience init(collectionView: ChatCollectionView) { + self.init(collectionView: collectionView, cellProvider: Self.cellProvider) + } +} diff --git a/ChatBot/ChatBot/View/ChatInputSendButton.swift b/ChatBot/ChatBot/View/ChatInputSendButton.swift new file mode 100644 index 00000000..d3ce44cd --- /dev/null +++ b/ChatBot/ChatBot/View/ChatInputSendButton.swift @@ -0,0 +1,19 @@ +// +// ChatSendButton.swift +// ChatBot +// +// Created by 이보한 on 2024/4/8. +// + +import UIKit + +final class ChatInputSendButton: UIButton { + override init(frame: CGRect) { + super.init(frame: frame) + self.setBackgroundImage(UIImage(systemName: "arrow.up.circle.fill"), for: .normal) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/ChatBot/ChatBot/View/ChatInputTextView.swift b/ChatBot/ChatBot/View/ChatInputTextView.swift new file mode 100644 index 00000000..f4f021d4 --- /dev/null +++ b/ChatBot/ChatBot/View/ChatInputTextView.swift @@ -0,0 +1,32 @@ +// +// ChatInputTextView.swift +// ChatBot +// +// Created by 이보한 on 2024/4/8. +// + +import UIKit + +final class ChatInputTextView: UITextView { + + override init(frame: CGRect, textContainer: NSTextContainer?) { + super.init(frame: frame, textContainer: textContainer) + configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension ChatInputTextView { + func configureUI() { + self.font = .preferredFont(forTextStyle: .body) + self.layer.borderWidth = 1 + self.layer.cornerRadius = 17 + self.layer.borderColor = UIColor.systemGray2.cgColor + self.isScrollEnabled = false + self.textContainerInset.left = 10 + self.textContainerInset.right = 10 + } +} diff --git a/ChatBot/ChatBot/View/ChatInputView.swift b/ChatBot/ChatBot/View/ChatInputView.swift new file mode 100644 index 00000000..8f23d848 --- /dev/null +++ b/ChatBot/ChatBot/View/ChatInputView.swift @@ -0,0 +1,66 @@ +// +// ChatInputView.swift +// ChatBot +// +// Created by 이보한 on 2024/4/8. +// + +import UIKit + +final class ChatInputView: UIView { + private let chatInputTextView = ChatInputTextView() + private let chatSendButton = ChatInputSendButton() + var isEnable: Bool { + get { + chatSendButton.isEnabled + } + set { + DispatchQueue.main.async { + self.chatSendButton.isEnabled = newValue + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setChatSendButton(completion: @escaping (_ message: Message) -> Void) { + let action = UIAction { [weak self] _ in + guard let text = self?.chatInputTextView.text else { return } + completion(Message(role: "user", content: text)) + self?.chatInputTextView.text = "" + } + self.chatSendButton.addAction(action, for: .touchUpInside) + } +} + +private extension ChatInputView { + func configureUI() { + self.addSubview(chatInputTextView) + self.addSubview(chatSendButton) + setupConstraints() + } + + func setupConstraints() { + chatInputTextView.translatesAutoresizingMaskIntoConstraints = false + chatSendButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + chatInputTextView.leadingAnchor.constraint(equalTo: self.leadingAnchor,constant: 15), + chatInputTextView.trailingAnchor.constraint(equalTo: chatSendButton.leadingAnchor, constant: -5), + chatInputTextView.topAnchor.constraint(equalTo: self.topAnchor, constant: 5), + chatInputTextView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + + chatSendButton.widthAnchor.constraint(equalToConstant: UIScreen.main.bounds.height / 20), + chatSendButton.heightAnchor.constraint(equalTo: chatSendButton.widthAnchor), + chatSendButton.centerYAnchor.constraint(equalTo: chatInputTextView.centerYAnchor), + chatSendButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -15) + ]) + } +} diff --git a/ChatBot/ChatBot/View/ChattingView.swift b/ChatBot/ChatBot/View/ChattingView.swift new file mode 100644 index 00000000..88da39bc --- /dev/null +++ b/ChatBot/ChatBot/View/ChattingView.swift @@ -0,0 +1,79 @@ +// +// ChattingView.swift +// ChatBot +// +// Created by 강창현 on 4/21/24. +// + +import UIKit + +final class ChattingView: UIView { + private lazy var chatCollectionView: ChatCollectionView = { + var configure = UICollectionLayoutListConfiguration(appearance: .plain) + configure.showsSeparators = false + let layout = UICollectionViewCompositionalLayout.list(using: configure) + let collectionView = ChatCollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.allowsSelection = false + return collectionView + }() + + private lazy var dataSource = ChatCollectionViewDataSource(collectionView: chatCollectionView) + + private let chatInputView = ChatInputView() + + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + setupCollectionView() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setChatSendButton(completion: @escaping (_ message: Message) -> Void) { + chatInputView.setChatSendButton(completion: completion) + } + + func isSendButtonEnable(_ isEnable: Bool) { + chatInputView.isEnable = isEnable + } + + func applyChatResponse(response: [RequestDTO]) { + var chatCollectionViewSnapshot = ChatCollectionViewSnapshot() + chatCollectionViewSnapshot.appendSections([.messages]) + chatCollectionViewSnapshot.appendItems(response) + dataSource.apply(chatCollectionViewSnapshot) + chatCollectionView.scrollToBottom() + } +} + +private extension ChattingView { + func configureUI() { + self.backgroundColor = .white + self.addSubview(chatCollectionView) + self.addSubview(chatInputView) + } + + func setupCollectionView() { + self.chatCollectionView.dataSource = dataSource + } + + func setupConstraints() { + chatInputView.translatesAutoresizingMaskIntoConstraints = false + chatCollectionView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate( + [ + chatCollectionView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + chatCollectionView.topAnchor.constraint(equalTo: self.safeAreaLayoutGuide.topAnchor), + chatCollectionView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + chatCollectionView.bottomAnchor.constraint(equalTo: chatInputView.topAnchor, constant: -10), + chatInputView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + chatInputView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + chatInputView.bottomAnchor.constraint(equalTo: self.safeAreaLayoutGuide.bottomAnchor) + ] + ) + } +} diff --git a/ChatBot/ChatBot/View/MessageLabel.swift b/ChatBot/ChatBot/View/MessageLabel.swift new file mode 100644 index 00000000..a7136b05 --- /dev/null +++ b/ChatBot/ChatBot/View/MessageLabel.swift @@ -0,0 +1,28 @@ +// +// MessageView.swift +// ChatBot +// +// Created by 이보한 on 2024/4/4. +// + +import UIKit + +final class MessageLabel: UILabel { + + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private extension MessageLabel { + func configureUI() { + self.backgroundColor = .clear + self.textAlignment = .left + self.numberOfLines = 0 + } +} diff --git a/ChatBot/ChatBot/ViewModel/ChatViewModel.swift b/ChatBot/ChatBot/ViewModel/ChatViewModel.swift index 99601c06..10e763f8 100644 --- a/ChatBot/ChatBot/ViewModel/ChatViewModel.swift +++ b/ChatBot/ChatBot/ViewModel/ChatViewModel.swift @@ -9,11 +9,11 @@ import Foundation import Combine final class ChatViewModel { - private var requestModel: RequestModel? private let networkService: NetworkService private let output: PassthroughSubject = .init() private var cancellables: Set = .init() + var requestDTO: [RequestDTO] = [] enum Input { case sendButtonTapped(message: Message) @@ -21,7 +21,7 @@ final class ChatViewModel { enum Output { case fetchChatResponseDidFail(error: NetworkError) - case fetchChatResponseDidSucceed(response: ResponseModel) + case fetchChatResponseDidSucceed(response: [RequestDTO]) case toggleSendButton(isEnable: Bool) } @@ -33,7 +33,8 @@ final class ChatViewModel { input.sink { [weak self] event in switch event { case .sendButtonTapped(let message): - self?.fetchChatBotData(message: message) + guard let body = self?.makeBody(message: message) else { return } + self?.fetchChatBotData(body: body) } }.store(in: &cancellables) return output.eraseToAnyPublisher() @@ -41,8 +42,7 @@ final class ChatViewModel { } private extension ChatViewModel { - func fetchChatBotData(message: Message) { - let body = makeBody(message: message) + func fetchChatBotData(body: RequestModel) { self.output.send(.toggleSendButton(isEnable: false)) networkService.fetchChatBotResponse( type: .chatbot, @@ -56,12 +56,21 @@ private extension ChatViewModel { self?.output.send(.fetchChatResponseDidFail(error: error)) } } receiveValue: { [weak self] response in - self?.output.send(.fetchChatResponseDidSucceed(response: response)) + self?.requestDTO.removeLast() + self?.requestDTO.append( + RequestDTO( + id: UUID(), + message: response.choices[0].message + ) + ) + guard let requestDTO = self?.requestDTO else { return } + self?.output.send(.fetchChatResponseDidSucceed(response: requestDTO)) } .store(in: &cancellables) } func makeBody(message: Message) -> RequestModel? { + let responseMessage = Message(role: "assistant", content: "● ● ●") guard requestModel != nil else { @@ -74,9 +83,22 @@ private extension ChatViewModel { message ] ) + requestModel?.messages.append(message) + setRequestDTO(message, responseMessage) return requestModel } requestModel?.messages.append(message) + setRequestDTO(message, responseMessage) return requestModel } + + func setRequestDTO(_ message: Message, _ responseMessage: Message) { + requestDTO.append( + RequestDTO(id: UUID(), message: message) + ) + requestDTO.append( + RequestDTO(id: UUID(), message: responseMessage) + ) + } } +