diff --git a/Projects/Chat/Sources/View/ChatViewController.swift b/Projects/Chat/Sources/View/ChatViewController.swift index e5aee2b..5811540 100644 --- a/Projects/Chat/Sources/View/ChatViewController.swift +++ b/Projects/Chat/Sources/View/ChatViewController.swift @@ -5,4 +5,126 @@ // Created by λ°•μ§€μœ€ on 7/1/25. // +import UIKit +import CommonUI +import RxSwift +import Domain +public class ChatViewController: BaseViewController { + let viewModel: ChatViewModel + let chatView = ChatView() + + private var messages: [ChatMessageVO] = [] + + public init(chatViewModel: ChatViewModel) { + self.viewModel = chatViewModel + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController?.setNavigationBarHidden(true, animated: false) + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupViewProperty() + setupHierarchy() + setupLayout() + bindData() + bindActions() + setupTextFieldActions() + viewModel.startTextChat() + } + + public override func setupViewProperty() { + view.backgroundColor = CommonUIAssets.LMOrange4 + } + + public override func setupHierarchy() { + view.addSubview(chatView) + } + + public override func setupDelegate() { + } + + public override func setupLayout() { + chatView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + private func bindData() { + viewModel.chatSubject + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] (chat: ChatVO) in + print("πŸ“¨ ChatVO μˆ˜μ‹ : \(chat)") + self?.updateRecommendTopics(chat.recommendSubjects) + }) + .disposed(by: disposeBag) + + viewModel.messageSubject + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] (message: ChatMessageVO) in + print("πŸ“¨ λ©”μ‹œμ§€ μˆ˜μ‹ : \(message)") + self?.addMessageToUI(message) + }) + .disposed(by: disposeBag) + } + + private func bindActions() { + chatView.onSendButtonTapped = { [weak self] message in + self?.sendMessage(message) + } + } + + private func setupTextFieldActions() { + chatView.chatTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) + } + + @objc private func textFieldDidChange() { + let text = chatView.chatTextField.text ?? "" + chatView.sendButton.isEnabled = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func sendMessage(_ text: String) { + // 첫 번째 λ©”μ‹œμ§€ 전솑 μ‹œ μΆ”μ²œ μ„Ήμ…˜ 숨기기 + if messages.isEmpty { + chatView.hideRecommendSection() + } + + // μ‚¬μš©μž λ©”μ‹œμ§€ UI에 μΆ”κ°€ + let userMessage = ChatMessageVO(chatId: 0, author: "HUMAN", content: text) + addMessageToUI(userMessage) + + // API 호좜 + viewModel.sendMessage(content: text) + + // ν…μŠ€νŠΈ ν•„λ“œ μ΄ˆκΈ°ν™” 및 λ²„νŠΌ λΉ„ν™œμ„±ν™” + chatView.chatTextField.text = "" + chatView.sendButton.isEnabled = false + } + + private func addMessageToUI(_ message: ChatMessageVO) { + messages.append(message) + chatView.addMessageToUI(message) + } + + private func updateRecommendTopics(_ topics: [String]) { + print("πŸ”„ μΆ”μ²œ 주제 μ—…λ°μ΄νŠΈ: \(topics)") + + guard !topics.isEmpty else { + print("⚠️ μΆ”μ²œ μ£Όμ œκ°€ λΉ„μ–΄μžˆμŠ΅λ‹ˆλ‹€") + return + } + + // ChatView의 recommendTexts ν”„λ‘œνΌν‹°λ‘œ κ°„λ‹¨ν•˜κ²Œ μ—…λ°μ΄νŠΈ + chatView.recommendTexts = topics + + print("βœ… μΆ”μ²œ 주제 μ—…λ°μ΄νŠΈ μ™„λ£Œ: \(topics.count)개") + } +} diff --git a/Projects/Chat/Sources/ViewModel/ChatViewModel.swift b/Projects/Chat/Sources/ViewModel/ChatViewModel.swift index 2fbe7f4..c9000c7 100644 --- a/Projects/Chat/Sources/ViewModel/ChatViewModel.swift +++ b/Projects/Chat/Sources/ViewModel/ChatViewModel.swift @@ -4,3 +4,53 @@ // // Created by λ°•μ§€μœ€ on 7/1/25. // + +import Domain +import RxSwift + +protocol ChatViewModelProtocol { + func startTextChat() + func sendMessage(content: String) +} + +public class ChatViewModel: ChatViewModelProtocol { + private let disposeBag = DisposeBag() + private let chatUseCase: ChatUseCase + private let tokenUseCase: TokenUseCase + + let chatSubject = PublishSubject() + let messageSubject = PublishSubject() + private var currentChatRoomId: Int = 0 + + public init(chatUseCase: ChatUseCase, + tokenUseCase: TokenUseCase) { + self.chatUseCase = chatUseCase + self.tokenUseCase = tokenUseCase + } + + func startTextChat() { + chatUseCase.postChatStart() + .subscribe(onSuccess: { [weak self] chat in + print("βœ… ν…μŠ€νŠΈ λŒ€ν™” μ‹œμž‘ 성곡: \(chat)") + self?.currentChatRoomId = chat.chatRoomId + self?.chatSubject.onNext(chat) + }, onFailure: { error in + print("❌ ν…μŠ€νŠΈ λŒ€ν™” μ‹œμž‘ μ‹€νŒ¨: \(error)") + }).disposed(by: disposeBag) + } + + func sendMessage(content: String) { + guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + print("⚠️ 빈 λ©”μ‹œμ§€λŠ” 전솑할 수 μ—†μŠ΅λ‹ˆλ‹€") + return + } + + chatUseCase.postChat(chatRoomId: currentChatRoomId, content: content) + .subscribe(onSuccess: { [weak self] message in + print("βœ… λ©”μ‹œμ§€ 전솑 성곡: \(message)") + self?.messageSubject.onNext(message) + }, onFailure: { error in + print("❌ λ©”μ‹œμ§€ 전솑 μ‹€νŒ¨: \(error)") + }).disposed(by: disposeBag) + } +} diff --git a/Projects/CommonUI/CommonUI.xcodeproj/project.pbxproj b/Projects/CommonUI/CommonUI.xcodeproj/project.pbxproj index 1ae3142..473999c 100644 --- a/Projects/CommonUI/CommonUI.xcodeproj/project.pbxproj +++ b/Projects/CommonUI/CommonUI.xcodeproj/project.pbxproj @@ -194,13 +194,21 @@ path = Extension; sourceTree = ""; }; + 951F3F852E6DDE7F0022583B /* Chat */ = { + isa = PBXGroup; + children = ( + 3EBDD10D8389EBB23B42C54F /* ChatView.swift */, + ); + path = Chat; + sourceTree = ""; + }; 9786E1056828B1E6D5EDAB7C /* View */ = { isa = PBXGroup; children = ( DFA6DB2905DB98838A8AEA6F /* Home */, 4F020027E0FF96E7A77E5F05 /* Login */, 7F5AEF3C3719E8689E7AF017 /* Navigation */, - 3EBDD10D8389EBB23B42C54F /* ChatView.swift */, + 951F3F852E6DDE7F0022583B /* Chat */, C28FE6392E1612667826E5C5 /* DiaryView.swift */, 9266801EFBA6178C5B70C641 /* MyPageView.swift */, ); diff --git a/Projects/CommonUI/Sources/Assets/Colors.xcassets/LMPointColor/LMBlue02.colorset/Contents.json b/Projects/CommonUI/Sources/Assets/Colors.xcassets/LMPointColor/LMBlue02.colorset/Contents.json new file mode 100644 index 0000000..ad76e7f --- /dev/null +++ b/Projects/CommonUI/Sources/Assets/Colors.xcassets/LMPointColor/LMBlue02.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEA", + "green" : "0x77", + "red" : "0x1A" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEA", + "green" : "0x77", + "red" : "0x1A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/send.imageset/Contents.json b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/send.imageset/Contents.json new file mode 100644 index 0000000..69d2bb8 --- /dev/null +++ b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/send.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "send@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "send@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/send.imageset/send@2x.png b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/send.imageset/send@2x.png new file mode 100644 index 0000000..fda7299 Binary files /dev/null and b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/send.imageset/send@2x.png differ diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/send.imageset/send@3x.png b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/send.imageset/send@3x.png new file mode 100644 index 0000000..9fc1db5 Binary files /dev/null and b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/send.imageset/send@3x.png differ diff --git a/Projects/CommonUI/Sources/Enum/CommonUIAssets.swift b/Projects/CommonUI/Sources/Enum/CommonUIAssets.swift index c07b092..9447024 100644 --- a/Projects/CommonUI/Sources/Enum/CommonUIAssets.swift +++ b/Projects/CommonUI/Sources/Enum/CommonUIAssets.swift @@ -49,6 +49,7 @@ public enum CommonUIAssets { /// * Icon public static let IconPlay = image(named: "play") public static let IconBack = image(named: "back") + public static let IconSend = image(named: "send") /// color public static let LMOrange1 = color(named: "LMOrange01") @@ -62,6 +63,7 @@ public enum CommonUIAssets { public static let LMGray5 = color(named: "LMGray05") public static let LMGray6 = color(named: "LMGray06") public static let LMBlue = color(named: "LMBlue") + public static let LMBlue2 = color(named: "LMBlue02") public static let LMGreen = color(named: "LMGreen") public static let LMRed = color(named: "LMRed") public static let LMRed2 = color(named: "LMRed02") diff --git a/Projects/CommonUI/Sources/View/Chat/ChatView.swift b/Projects/CommonUI/Sources/View/Chat/ChatView.swift new file mode 100644 index 0000000..3ab2d84 --- /dev/null +++ b/Projects/CommonUI/Sources/View/Chat/ChatView.swift @@ -0,0 +1,291 @@ +// +// ChatView.swift +// CommonUI +// +// Created by λ°•μ§€μœ€ on 7/1/25. +// + +import Domain +import UIKit +import SnapKit +import RxSwift +import Then +import RxRelay + +open class ChatView: UIView { + let titleLabel = UILabel().then { + $0.text = "AI와 ν…μŠ€νŠΈλ‘œ λŒ€ν™”ν•˜μ„Έμš”" + $0.textColor = .black + $0.font = UIFont.systemFont(ofSize: 24, weight: .semibold) + } + + let subtitleLabel = UILabel().then { + $0.text = "λ©”μ„Έμ§€λ₯Ό μž…λ ₯ν•˜κ³  μ „μ†‘ν•΄λ³΄μ„Έμš”" + $0.textColor = CommonUIAssets.LMGray3 + $0.font = UIFont.systemFont(ofSize: 15, weight: .regular) + } + + let recommendTitleLabel = UILabel().then { + $0.text = "AI μΆ”μ²œ λŒ€ν™” 주제" + $0.textColor = CommonUIAssets.LMGray1 + $0.font = UIFont.systemFont(ofSize: 18, weight: .regular) + } + + let endButton = UIButton().then { + $0.setTitle("λŒ€ν™” μ’…λ£Œν•˜κΈ°", for: .normal) + $0.setTitleColor(CommonUIAssets.LMGray1, for: .normal) + $0.titleLabel?.font = UIFont.systemFont(ofSize: 12, weight: .semibold) + $0.backgroundColor = CommonUIAssets.LMRed + $0.layer.cornerRadius = 10 + } + + public let recommendStackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 11 + } + + // μ±„νŒ… κ΄€λ ¨ UI μš”μ†Œλ“€ μΆ”κ°€ + let chatScrollView = UIScrollView().then { + $0.showsVerticalScrollIndicator = true + $0.alwaysBounceVertical = true + } + + let chatStackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 12 + $0.alignment = .fill + } + + // μΆ”μ²œ 뷰와 라벨을 λ°°μ—΄λ‘œ 관리 + private let recommendViews: [UIView] = (0..<3).map { _ in + UIView().then { + $0.backgroundColor = .white + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 1 + $0.layer.borderColor = CommonUIAssets.LMGray5?.cgColor + } + } + + private let recommendLabels: [UILabel] = (0..<3).map { _ in + UILabel().then { + $0.textColor = CommonUIAssets.LMGray3 + $0.font = UIFont.systemFont(ofSize: 16, weight: .regular) + $0.textAlignment = .center + $0.numberOfLines = 0 + } + } + + // μ™ΈλΆ€μ—μ„œ μ ‘κ·Όν•  수 μžˆλ„λ‘ public ν”„λ‘œνΌν‹° 제곡 + public var recommendTexts: [String] { + get { recommendLabels.map { $0.text ?? "" } } + set { + for (index, text) in newValue.enumerated() { + if index < recommendLabels.count { + recommendLabels[index].text = text + } + } + } + } + + public let chatTextField = UITextField().then { + $0.placeholder = "λ©”μ„Έμ§€λ₯Ό μž…λ ₯ν•˜μ„Έμš”..." + $0.backgroundColor = .white + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 1 + $0.layer.borderColor = CommonUIAssets.LMGray5?.cgColor + $0.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 0)) + $0.leftViewMode = .always + } + + public let sendButton = UIButton().then { + $0.setImage(CommonUIAssets.IconSend, for: .normal) + $0.backgroundColor = CommonUIAssets.LMBlue2 + $0.layer.cornerRadius = 22 + $0.isEnabled = false + } + + let disposeBag = DisposeBag() + + public var onSendButtonTapped: ((String) -> Void)? + + public override init(frame: CGRect) { + super.init(frame: frame) + initAttribute() + initUI() + bindEvents() + } + + func bindEvents() { + sendButton.rx.tap + .withLatestFrom(chatTextField.rx.text.orEmpty) + .subscribe(onNext: { [weak self] text in + self?.onSendButtonTapped?(text) + self?.chatTextField.text = "" + }) + .disposed(by: disposeBag) + } + + func initAttribute() { + self.backgroundColor = CommonUIAssets.LMOrange4 + } + + func initUI() { + [titleLabel, subtitleLabel, endButton, recommendTitleLabel, recommendStackView, chatScrollView, chatTextField, sendButton].forEach { self.addSubview($0) } + + // μ±„νŒ… μŠ€νƒλ·°λ₯Ό μŠ€ν¬λ‘€λ·°μ— μΆ”κ°€ + chatScrollView.addSubview(chatStackView) + + // μΆ”μ²œ 뷰듀을 StackView에 μΆ”κ°€ν•˜κ³  각각에 라벨 μΆ”κ°€ + for (view, label) in zip(recommendViews, recommendLabels) { + recommendStackView.addArrangedSubview(view) + view.addSubview(label) + + // λ·° 높이 μ„€μ • + view.snp.makeConstraints { $0.height.equalTo(40) } + + // 라벨 λ ˆμ΄μ•„μ›ƒ μ„€μ • + label.snp.makeConstraints { + $0.center.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(16) + } + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(self.safeAreaLayoutGuide).offset(20) + $0.leading.equalToSuperview().inset(20) + } + + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(10) + $0.leading.equalToSuperview().inset(20) + } + + endButton.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.height.equalTo(30) + $0.width.equalTo(82) + $0.trailing.equalToSuperview().inset(20) + } + + recommendTitleLabel.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.centerY.equalToSuperview().offset(-95) + } + + recommendStackView.snp.makeConstraints { + $0.width.equalTo(280) + $0.centerX.equalToSuperview() + $0.top.equalTo(recommendTitleLabel.snp.bottom).offset(18) + } + + // μ±„νŒ… 슀크둀뷰 λ ˆμ΄μ•„μ›ƒ (μ΄ˆκΈ°μ—λŠ” μΆ”μ²œ μ„Ήμ…˜ μ•„λž˜μ— μœ„μΉ˜) + chatScrollView.snp.makeConstraints { + $0.top.equalTo(recommendStackView.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalTo(chatTextField.snp.top).offset(-20) + } + + // μ±„νŒ… μŠ€νƒλ·° λ ˆμ΄μ•„μ›ƒ + chatStackView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.width.equalToSuperview() + } + + chatTextField.snp.makeConstraints { + $0.centerY.equalTo(sendButton) + $0.leading.equalToSuperview().inset(20) + $0.height.equalTo(44) + $0.trailing.equalTo(sendButton.snp.leading).offset(-20) + } + + sendButton.snp.makeConstraints { + $0.bottom.equalTo(self.safeAreaLayoutGuide).offset(-30) + $0.height.width.equalTo(44) + $0.trailing.equalToSuperview().inset(20) + } + } + + // μΆ”μ²œ μ„Ήμ…˜ 숨기기 λ©”μ„œλ“œ + public func hideRecommendSection() { + UIView.animate(withDuration: 0.3) { + self.recommendTitleLabel.alpha = 0 + self.recommendStackView.alpha = 0 + } completion: { _ in + self.recommendTitleLabel.isHidden = true + self.recommendStackView.isHidden = true + + // μ±„νŒ… μ˜μ—­μ„ μœ„λ‘œ ν™•μž₯ + self.chatScrollView.snp.remakeConstraints { + $0.top.equalTo(self.subtitleLabel.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalTo(self.chatTextField.snp.top).offset(-20) + } + + UIView.animate(withDuration: 0.3) { + self.layoutIfNeeded() + } + } + } + + // λ©”μ‹œμ§€ μΆ”κ°€ λ©”μ„œλ“œ + public func addMessageToUI(_ message: ChatMessageVO) { + let messageView = createMessageView(message) + chatStackView.addArrangedSubview(messageView) + + // μŠ€ν¬λ‘€μ„ 맨 μ•„λž˜λ‘œ + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let bottomOffset = CGPoint( + x: 0, + y: self.chatScrollView.contentSize.height - self.chatScrollView.bounds.height + ) + if bottomOffset.y > 0 { + self.chatScrollView.setContentOffset(bottomOffset, animated: true) + } + } + } + + // λ©”μ‹œμ§€ λ·° 생성 λ©”μ„œλ“œ + private func createMessageView(_ message: ChatMessageVO) -> UIView { + let containerView = UIView() + + let bubbleView = UIView().then { + $0.layer.cornerRadius = 16 + $0.backgroundColor = message.author == "HUMAN" ? CommonUIAssets.LMBlue2 : UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1.0) + } + + let messageLabel = UILabel().then { + $0.text = message.content + $0.textColor = message.author == "HUMAN" ? .white : .black + $0.font = .systemFont(ofSize: 16, weight: .regular) + $0.numberOfLines = 0 + } + + containerView.addSubview(bubbleView) + bubbleView.addSubview(messageLabel) + + // λ©”μ‹œμ§€ μ •λ ¬ (μ‚¬μš©μžλŠ” 였λ₯Έμͺ½, AIλŠ” μ™Όμͺ½) + if message.author == "HUMAN" { + bubbleView.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.trailing.equalToSuperview() + $0.width.lessThanOrEqualTo(250) + } + } else { + bubbleView.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.leading.equalToSuperview() + $0.width.lessThanOrEqualTo(250) + } + } + + messageLabel.snp.makeConstraints { + $0.edges.equalToSuperview().inset(12) + } + + return containerView + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Projects/CommonUI/Sources/View/ChatView.swift b/Projects/CommonUI/Sources/View/ChatView.swift deleted file mode 100644 index 341c8be..0000000 --- a/Projects/CommonUI/Sources/View/ChatView.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// ChatView.swift -// CommonUI -// -// Created by λ°•μ§€μœ€ on 7/1/25. -// diff --git a/Projects/Data/Data.xcodeproj/project.pbxproj b/Projects/Data/Data.xcodeproj/project.pbxproj index c9e0efb..e90f791 100644 --- a/Projects/Data/Data.xcodeproj/project.pbxproj +++ b/Projects/Data/Data.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ 901ACA7B98089AB702ADA830 /* Domain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06FAA1459D11CCE724C34195 /* Domain.framework */; }; 950A0D702E5CCF0200C07CF2 /* SignRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950A0D6F2E5CCEFD00C07CF2 /* SignRepository.swift */; }; 950A0D902E6039D600C07CF2 /* DefaultDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950A0D8F2E6039D300C07CF2 /* DefaultDTO.swift */; }; + 951F3F8F2E6F36450022583B /* ChatDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951F3F8E2E6F36440022583B /* ChatDTO.swift */; }; + 951F3F912E6F36680022583B /* ChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951F3F902E6F36640022583B /* ChatRepository.swift */; }; A334985695DC9388841BBC43 /* QuizRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CCA951B89F2C3D20AA31F7F /* QuizRepository.swift */; }; B2F8FBFA915F696CCCA4152A /* Alamofire.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A3B0D3D8C7049B6856791C1D /* Alamofire.framework */; }; E1BFC73FB539432F6E12CD94 /* CourseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0730BC3657E24BCEA511A3C /* CourseRepository.swift */; }; @@ -45,6 +47,8 @@ 77810122262C6CB16D4D47DA /* QuizDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizDTO.swift; sourceTree = ""; }; 950A0D6F2E5CCEFD00C07CF2 /* SignRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignRepository.swift; sourceTree = ""; }; 950A0D8F2E6039D300C07CF2 /* DefaultDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultDTO.swift; sourceTree = ""; }; + 951F3F8E2E6F36440022583B /* ChatDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDTO.swift; sourceTree = ""; }; + 951F3F902E6F36640022583B /* ChatRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRepository.swift; sourceTree = ""; }; A3B0D3D8C7049B6856791C1D /* Alamofire.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Alamofire.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A44BC1E2E75FC256F832CA38 /* LoginDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginDTO.swift; sourceTree = ""; }; AAAC2885ACFE998578DC25E8 /* CourseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDTO.swift; sourceTree = ""; }; @@ -77,6 +81,7 @@ 647255CD65221C9CD4A43DED /* DTO */ = { isa = PBXGroup; children = ( + 951F3F8E2E6F36440022583B /* ChatDTO.swift */, 950A0D8F2E6039D300C07CF2 /* DefaultDTO.swift */, AAAC2885ACFE998578DC25E8 /* CourseDTO.swift */, A44BC1E2E75FC256F832CA38 /* LoginDTO.swift */, @@ -88,6 +93,7 @@ 73F3ED55BFDC2EF15878F5B6 /* Repository */ = { isa = PBXGroup; children = ( + 951F3F902E6F36640022583B /* ChatRepository.swift */, 950A0D6F2E5CCEFD00C07CF2 /* SignRepository.swift */, B0730BC3657E24BCEA511A3C /* CourseRepository.swift */, 3D7624DE90CFBBEF778E120E /* LoginRepository.swift */, @@ -217,7 +223,9 @@ E9463A3FF42D5F0960245F80 /* QuizDTO.swift in Sources */, 684AAEA9796EED3F9FC592FC /* NetworkConfiguration.swift in Sources */, 950A0D702E5CCF0200C07CF2 /* SignRepository.swift in Sources */, + 951F3F8F2E6F36450022583B /* ChatDTO.swift in Sources */, E1BFC73FB539432F6E12CD94 /* CourseRepository.swift in Sources */, + 951F3F912E6F36680022583B /* ChatRepository.swift in Sources */, 34FD760EB97BB96E9D770BF0 /* LoginRepository.swift in Sources */, 950A0D902E6039D600C07CF2 /* DefaultDTO.swift in Sources */, A334985695DC9388841BBC43 /* QuizRepository.swift in Sources */, diff --git a/Projects/Data/Sources/DTO/ChatDTO.swift b/Projects/Data/Sources/DTO/ChatDTO.swift new file mode 100644 index 0000000..bad3905 --- /dev/null +++ b/Projects/Data/Sources/DTO/ChatDTO.swift @@ -0,0 +1,56 @@ +// +// ChatDTO.swift +// Data +// +// Created by λ°•μ§€μœ€ on 9/9/25. +// + +import Domain + +public struct ChatResponseDTO: Decodable { + public let is_success: Bool + public let code: String + public let message: String + public let data: ChatDataDTO +} + +public struct ChatDataDTO: Decodable { + public let chatRoomId: Int + public let recommendSubjects: [String] +} + +extension ChatDataDTO { + func toDomain() -> ChatVO { + return ChatVO( + chatRoomId: chatRoomId, + recommendSubjects: recommendSubjects + ) + } +} + +public struct ChatMessageResponseDTO: Decodable { + public let is_success: Bool + public let code: String + public let message: String + public let data: ChatMessageDataDTO +} + +public struct ChatMessageDataDTO: Decodable { + public let chatId: Int + public let author: String + public let content: String +} + +public struct ChatMessageRequestDTO: Encodable { + public let content: String +} + +extension ChatMessageDataDTO { + func toDomain() -> ChatMessageVO { + return ChatMessageVO( + chatId: chatId, + author: author, + content: content + ) + } +} diff --git a/Projects/Data/Sources/Repository/ChatRepository.swift b/Projects/Data/Sources/Repository/ChatRepository.swift new file mode 100644 index 0000000..38ac1a8 --- /dev/null +++ b/Projects/Data/Sources/Repository/ChatRepository.swift @@ -0,0 +1,84 @@ +// +// ChatRepository.swift +// Data +// +// Created by λ°•μ§€μœ€ on 9/9/25. +// + +import Domain +import RxSwift +import Alamofire + +public class DefaultChatRepository: ChatRepository { + private let tokenRepository: TokenRepository + + public init(tokenRepository: TokenRepository) { + self.tokenRepository = tokenRepository + } + + public func postChatStart() -> Single { + return Single.create { single in + let url = "\(NetworkConfiguration.baseUrl)/api/chats/text" + var headers: HTTPHeaders = [:] + + if let token = self.tokenRepository.getAccessToken() { + headers.add(name: "Authorization", value: "Bearer \(token)") + } + + print("[ν…μŠ€νŠΈ λŒ€ν™” μ‹œμž‘ POST] URL: \(url)") + print("[ν…μŠ€νŠΈ λŒ€ν™” μ‹œμž‘ POST] 헀더: \(headers)") + + let request = AF.request(url, + method: .post, + encoding: JSONEncoding.default, + headers: headers) + .validate() + .responseDecodable(of: ChatResponseDTO.self) { response in + switch response.result { + case .success(let value): + print("[ν…μŠ€νŠΈ λŒ€ν™” μ‹œμž‘ POST] 성곡: \(value)") + single(.success(value.data.toDomain())) + case .failure(let error): + print("[ν…μŠ€νŠΈ λŒ€ν™” μ‹œμž‘ POST] μ‹€νŒ¨: \(error)") + single(.failure(error)) + } + } + return Disposables.create { request.cancel() } + } + } + + public func postChat(chatRoomId: Int, content: String) -> Single { + return Single.create { single in + let url = "\(NetworkConfiguration.baseUrl)/api/chats/text/\(chatRoomId)" + var headers: HTTPHeaders = [:] + + if let token = self.tokenRepository.getAccessToken() { + headers.add(name: "Authorization", value: "Bearer \(token)") + } + + let requestBody = ChatMessageRequestDTO(content: content) + + print("[λ©”μ‹œμ§€ 전솑 POST] URL: \(url)") + print("[λ©”μ‹œμ§€ 전솑 POST] 헀더: \(headers)") + print("[λ©”μ‹œμ§€ 전솑 POST] μš”μ²­ λ‚΄μš©: \(content)") + + let request = AF.request(url, + method: .post, + parameters: requestBody, + encoder: JSONParameterEncoder.default, + headers: headers) + .validate() + .responseDecodable(of: ChatMessageResponseDTO.self) { response in + switch response.result { + case .success(let value): + print("[λ©”μ‹œμ§€ 전솑 POST] 성곡: \(value)") + single(.success(value.data.toDomain())) + case .failure(let error): + print("[λ©”μ‹œμ§€ 전솑 POST] μ‹€νŒ¨: \(error)") + single(.failure(error)) + } + } + return Disposables.create { request.cancel() } + } + } +} diff --git a/Projects/Data/Sources/Repository/TokenRepository.swift b/Projects/Data/Sources/Repository/TokenRepository.swift index 59438cd..c3fc51a 100644 --- a/Projects/Data/Sources/Repository/TokenRepository.swift +++ b/Projects/Data/Sources/Repository/TokenRepository.swift @@ -17,7 +17,7 @@ public class DefaultTokenRepository: TokenRepository { // Initialization if needed } - public func saveAccessToken(_ token: String) { + public func saveAccessToken(token: String) { userDefaults.set(token, forKey: accessToken) print("βœ… Access Token saved: \(token)") } diff --git a/Projects/Domain/Domain.xcodeproj/project.pbxproj b/Projects/Domain/Domain.xcodeproj/project.pbxproj index 27d2c4a..30ba42e 100644 --- a/Projects/Domain/Domain.xcodeproj/project.pbxproj +++ b/Projects/Domain/Domain.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ 950A0D6B2E5CCBF000C07CF2 /* SignRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950A0D6A2E5CCBE800C07CF2 /* SignRepository.swift */; }; 950A0D822E5DE10C00C07CF2 /* LoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950A0D812E5DE10700C07CF2 /* LoginUseCase.swift */; }; 950A0D922E603DA100C07CF2 /* DefaultVO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950A0D912E603D9C00C07CF2 /* DefaultVO.swift */; }; + 951F3F892E6DE1F80022583B /* ChatUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951F3F882E6DE1F60022583B /* ChatUseCase.swift */; }; + 951F3F8B2E6DE2140022583B /* ChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951F3F8A2E6DE2100022583B /* ChatRepository.swift */; }; + 951F3F8D2E6F360C0022583B /* ChatVO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 951F3F8C2E6F36090022583B /* ChatVO.swift */; }; 95369A7E2E28B8D9000C893F /* UIImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95369A7D2E28B8D1000C893F /* UIImage+Extension.swift */; }; A0CEC5A2E5A76EBD987CE37D /* StepVO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 798CFC331192C9FE77F3446A /* StepVO.swift */; }; B0B0382EF5DFDACDD3EB8A75 /* QuizUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F4565F6A02AFBF3D9A0D41B /* QuizUseCase.swift */; }; @@ -55,6 +58,9 @@ 950A0D6A2E5CCBE800C07CF2 /* SignRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignRepository.swift; sourceTree = ""; }; 950A0D812E5DE10700C07CF2 /* LoginUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUseCase.swift; sourceTree = ""; }; 950A0D912E603D9C00C07CF2 /* DefaultVO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultVO.swift; sourceTree = ""; }; + 951F3F882E6DE1F60022583B /* ChatUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUseCase.swift; sourceTree = ""; }; + 951F3F8A2E6DE2100022583B /* ChatRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRepository.swift; sourceTree = ""; }; + 951F3F8C2E6F36090022583B /* ChatVO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatVO.swift; sourceTree = ""; }; 95369A7D2E28B8D1000C893F /* UIImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Extension.swift"; sourceTree = ""; }; 9A2E30672E510822AAF38EAD /* RxSwift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = RxSwift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; ABB8C62A6FE12783AD3819BE /* TokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenUseCase.swift; sourceTree = ""; }; @@ -118,6 +124,7 @@ 2BE278B80FDD95404961E580 /* UseCase */ = { isa = PBXGroup; children = ( + 951F3F882E6DE1F60022583B /* ChatUseCase.swift */, 950A0D672E5CCBAC00C07CF2 /* Login */, F904C836B5CD2D4DA5EAE38D /* CourseUseCase.swift */, 6F4565F6A02AFBF3D9A0D41B /* QuizUseCase.swift */, @@ -155,6 +162,7 @@ 9C17CEB3595F4C3FA69C5BAF /* VO */ = { isa = PBXGroup; children = ( + 951F3F8C2E6F36090022583B /* ChatVO.swift */, 950A0D912E603D9C00C07CF2 /* DefaultVO.swift */, 747DBBCAB797E5E0A82177F2 /* CourseVO.swift */, FD08A7186FB9676854B7AEAC /* LoginVO.swift */, @@ -167,6 +175,7 @@ BC1A55AF7DC675AFEA6E7C12 /* RepositoryProtocol */ = { isa = PBXGroup; children = ( + 951F3F8A2E6DE2100022583B /* ChatRepository.swift */, 950A0D6A2E5CCBE800C07CF2 /* SignRepository.swift */, DCA3E06ED2A8180A63919B6C /* CourseRepository.swift */, 8A2AB67DE6AA50B3229C7A86 /* LoginRepository.swift */, @@ -260,12 +269,15 @@ B0B0382EF5DFDACDD3EB8A75 /* QuizUseCase.swift in Sources */, 3E835D4F2E5F639557AC7C06 /* TokenUseCase.swift in Sources */, 5EB8590C9D2C002D0BDBC021 /* CourseVO.swift in Sources */, + 951F3F8D2E6F360C0022583B /* ChatVO.swift in Sources */, + 951F3F892E6DE1F80022583B /* ChatUseCase.swift in Sources */, 950A0D922E603DA100C07CF2 /* DefaultVO.swift in Sources */, 0DED9075FE34124C093D9FDF /* LoginVO.swift in Sources */, 95369A7E2E28B8D9000C893F /* UIImage+Extension.swift in Sources */, 950A0D692E5CCBB900C07CF2 /* SignUseCase.swift in Sources */, CFBF3D58082C5F201FCFFD5B /* QuizVO.swift in Sources */, A0CEC5A2E5A76EBD987CE37D /* StepVO.swift in Sources */, + 951F3F8B2E6DE2140022583B /* ChatRepository.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Projects/Domain/Sources/RepositoryProtocol/ChatRepository.swift b/Projects/Domain/Sources/RepositoryProtocol/ChatRepository.swift new file mode 100644 index 0000000..d67b153 --- /dev/null +++ b/Projects/Domain/Sources/RepositoryProtocol/ChatRepository.swift @@ -0,0 +1,13 @@ +// +// ChatRepository.swift +// Domain +// +// Created by λ°•μ§€μœ€ on 9/8/25. +// + +import RxSwift + +public protocol ChatRepository { + func postChatStart() -> Single + func postChat(chatRoomId: Int, content: String) -> Single +} diff --git a/Projects/Domain/Sources/UseCase/ChatUseCase.swift b/Projects/Domain/Sources/UseCase/ChatUseCase.swift new file mode 100644 index 0000000..ceb1f46 --- /dev/null +++ b/Projects/Domain/Sources/UseCase/ChatUseCase.swift @@ -0,0 +1,29 @@ +// +// ChatUseCase.swift +// Domain +// +// Created by λ°•μ§€μœ€ on 9/8/25. +// + +import RxSwift + +public protocol ChatUseCase { + func postChatStart() -> Single + func postChat(chatRoomId: Int, content: String) -> Single +} + +public final class DefaultChatUseCase: ChatUseCase { + private let repository: ChatRepository + + public init(repository: ChatRepository) { + self.repository = repository + } + + public func postChatStart() -> Single { + return repository.postChatStart() + } + + public func postChat(chatRoomId: Int, content: String) -> Single { + return repository.postChat(chatRoomId: chatRoomId, content: content) + } +} diff --git a/Projects/Domain/Sources/UseCase/TokenUseCase.swift b/Projects/Domain/Sources/UseCase/TokenUseCase.swift index 09c434f..bd1ef8a 100644 --- a/Projects/Domain/Sources/UseCase/TokenUseCase.swift +++ b/Projects/Domain/Sources/UseCase/TokenUseCase.swift @@ -8,7 +8,7 @@ import RxSwift public protocol TokenUseCase { - func saveAccessToken(_ token: String) + func saveAccessToken(token: String) func getAccessToken() -> String? func clearAccessToken() } @@ -20,8 +20,8 @@ public final class DefaultTokenUseCase: TokenUseCase { self.repository = repository } - public func saveAccessToken(_ token: String) { - repository.saveAccessToken(token) + public func saveAccessToken(token: String) { + repository.saveAccessToken(token: token) } public func getAccessToken() -> String? { diff --git a/Projects/Domain/Sources/VO/ChatVO.swift b/Projects/Domain/Sources/VO/ChatVO.swift new file mode 100644 index 0000000..7e8b9c2 --- /dev/null +++ b/Projects/Domain/Sources/VO/ChatVO.swift @@ -0,0 +1,28 @@ +// +// ChatVO.swift +// Domain +// +// Created by λ°•μ§€μœ€ on 9/9/25. +// + +public struct ChatVO { + public let chatRoomId: Int + public let recommendSubjects: [String] + + public init(chatRoomId: Int, recommendSubjects: [String]) { + self.chatRoomId = chatRoomId + self.recommendSubjects = recommendSubjects + } +} + +public struct ChatMessageVO { + public let chatId: Int + public let author: String + public let content: String + + public init(chatId: Int, author: String, content: String) { + self.chatId = chatId + self.author = author + self.content = content + } +} diff --git a/Projects/LearnMate/Sources/Application/SceneDelegate.swift b/Projects/LearnMate/Sources/Application/SceneDelegate.swift index 6217074..8beab04 100644 --- a/Projects/LearnMate/Sources/Application/SceneDelegate.swift +++ b/Projects/LearnMate/Sources/Application/SceneDelegate.swift @@ -32,7 +32,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { injector.assemble([DataAssembly(), DomainAssembly(), LoginAssembly(), - HomeAssembly()]) + HomeAssembly(), + ChatAssembly()]) appCoordinator?.start() } @@ -60,7 +61,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { print("πŸ”‘ 토큰 길이: \(token.count)") let tokenRepository = injector.resolve(TokenRepository.self) - tokenRepository.saveAccessToken(token) + tokenRepository.saveAccessToken(token: token) // μ €μž₯된 토큰 확인 if let savedToken = tokenRepository.getAccessToken() { diff --git a/Projects/LearnMate/Sources/Coordinator/TabBarCoordinator.swift b/Projects/LearnMate/Sources/Coordinator/TabBarCoordinator.swift index 1b4439d..97d0b2f 100644 --- a/Projects/LearnMate/Sources/Coordinator/TabBarCoordinator.swift +++ b/Projects/LearnMate/Sources/Coordinator/TabBarCoordinator.swift @@ -9,6 +9,7 @@ import CommonUI import Login import Home import UIKit +import Chat protocol TabBarCoordinator: Coordinator { var tabBarController: UITabBarController { get } @@ -91,9 +92,11 @@ final class DefaultTabBarController: TabBarCoordinator { private func startTabCoordinator(of page: TabBarPage, to tabNavigationController: UINavigationController) { switch page { case .home: - // μΆ”ν›„ homeCoordinator 둜 λ³€κ²½ let homeViewController = dependency.injector.resolve(HomeViewController.self) tabNavigationController.pushViewController(homeViewController, animated: true) + case .chat: + let chatViewController = dependency.injector.resolve(ChatViewController.self) + tabNavigationController.pushViewController(chatViewController, animated: true) default: let viewController = UIViewController() viewController.view.backgroundColor = .black diff --git a/Projects/LearnMate/Sources/DI/ChatAssembly.swift b/Projects/LearnMate/Sources/DI/ChatAssembly.swift index 302a350..6459851 100644 --- a/Projects/LearnMate/Sources/DI/ChatAssembly.swift +++ b/Projects/LearnMate/Sources/DI/ChatAssembly.swift @@ -5,3 +5,22 @@ // Created by λ°•μ§€μœ€ on 7/2/25. // +import Swinject +import Chat +import Domain + +public struct ChatAssembly: Assembly { + public func assemble(container: Container) { + container.register(ChatViewModel.self) { resolver in + let chatUseCase = resolver.resolve(ChatUseCase.self)! + let tokenUseCase = resolver.resolve(TokenUseCase.self)! + return ChatViewModel(chatUseCase: chatUseCase, + tokenUseCase: tokenUseCase) + } + + container.register(ChatViewController.self) { resolver in + let chatViewModel = resolver.resolve(ChatViewModel.self)! + return ChatViewController(chatViewModel: chatViewModel) + } + } +} diff --git a/Projects/LearnMate/Sources/DI/DataAssembly.swift b/Projects/LearnMate/Sources/DI/DataAssembly.swift index 2e969cf..ace87bc 100644 --- a/Projects/LearnMate/Sources/DI/DataAssembly.swift +++ b/Projects/LearnMate/Sources/DI/DataAssembly.swift @@ -32,5 +32,10 @@ public struct DataAssembly: Assembly { container.register(SignRepository.self) { _ in return DefaultSignRepository() } + + container.register(ChatRepository.self) { resolver in + let tokenRepository = resolver.resolve(TokenRepository.self)! + return DefaultChatRepository(tokenRepository: tokenRepository) + } } } diff --git a/Projects/LearnMate/Sources/DI/DomainAssembly.swift b/Projects/LearnMate/Sources/DI/DomainAssembly.swift index 6fdb825..38f4ffa 100644 --- a/Projects/LearnMate/Sources/DI/DomainAssembly.swift +++ b/Projects/LearnMate/Sources/DI/DomainAssembly.swift @@ -34,5 +34,10 @@ public struct DomainAssembly: Assembly { let repository = resolver.resolve(SignRepository.self)! return DefaultSignUseCase(repository: repository) } + + container.register(ChatUseCase.self) { resolver in + let repository = resolver.resolve(ChatRepository.self)! + return DefaultChatUseCase(repository: repository) + } } }