diff --git a/Projects/Chat/Chat.xcodeproj/project.pbxproj b/Projects/Chat/Chat.xcodeproj/project.pbxproj index 4d48404..3848aaa 100644 --- a/Projects/Chat/Chat.xcodeproj/project.pbxproj +++ b/Projects/Chat/Chat.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 55; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ @@ -12,6 +12,9 @@ 7F3F4DE30EE902DEABC6040E /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AAFFC509053CC99EAA3B277 /* ChatViewModel.swift */; }; 9179FB9B1C2AEDCB7A3CFC7E /* ChatCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588CFD307FD55B64676DA657 /* ChatCoordinator.swift */; }; 91C973D65FBDD1ABF65A1A2A /* Domain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F910F30DEBC80ABF5CC5A6F9 /* Domain.framework */; }; + 9516ED482E7076F800F548A1 /* ChatAnalysisViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9516ED472E7076E900F548A1 /* ChatAnalysisViewController.swift */; }; + 9516ED4F2E708CE100F548A1 /* ChatMainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9516ED4E2E708CD900F548A1 /* ChatMainViewController.swift */; }; + 956C4D722E7690C600E32F93 /* ChatDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956C4D712E7690BF00E32F93 /* ChatDetailViewController.swift */; }; B6F14AC32696F30284073B2F /* Chat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EEE28C25EAD3F5DC9E76FFAC /* Chat.framework */; }; B89B886F288E5BDECC82BA50 /* Common.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 64BD6CA2A2FDA5A5D9C4A87D /* Common.framework */; }; E096380D552BAA6326ED8397 /* CommonUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB61D7206C15B58E35E2DEB /* CommonUI.framework */; }; @@ -60,6 +63,9 @@ 5AAFFC509053CC99EAA3B277 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = ""; }; 5E359E9093579B130E0EDD53 /* Then.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Then.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 64BD6CA2A2FDA5A5D9C4A87D /* Common.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Common.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9516ED472E7076E900F548A1 /* ChatAnalysisViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAnalysisViewController.swift; sourceTree = ""; }; + 9516ED4E2E708CD900F548A1 /* ChatMainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMainViewController.swift; sourceTree = ""; }; + 956C4D712E7690BF00E32F93 /* ChatDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDetailViewController.swift; sourceTree = ""; }; BC90A71D97E9F538AACFD586 /* SnapKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SnapKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C8A6CF81DAC793585959F31D /* Chat-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Chat-Info.plist"; sourceTree = ""; }; E05B48A08FE1A700DE3FEE63 /* RxSwift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = RxSwift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -154,7 +160,10 @@ B076E5D7F9914D43D18020A7 /* View */ = { isa = PBXGroup; children = ( + 9516ED4E2E708CD900F548A1 /* ChatMainViewController.swift */, E083003E4769EA2B259F4BE8 /* ChatViewController.swift */, + 9516ED472E7076E900F548A1 /* ChatAnalysisViewController.swift */, + 956C4D712E7690BF00E32F93 /* ChatDetailViewController.swift */, ); path = View; sourceTree = ""; @@ -227,8 +236,6 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - TargetAttributes = { - }; }; buildConfigurationList = 5BF2D8A0F20A5563CE9A6AEB /* Build configuration list for PBXProject "Chat" */; compatibilityVersion = "Xcode 14.0"; @@ -278,9 +285,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9516ED4F2E708CE100F548A1 /* ChatMainViewController.swift in Sources */, 9179FB9B1C2AEDCB7A3CFC7E /* ChatCoordinator.swift in Sources */, 11E1631C9C29E4197C2782CB /* ChatViewController.swift in Sources */, 7F3F4DE30EE902DEABC6040E /* ChatViewModel.swift in Sources */, + 9516ED482E7076F800F548A1 /* ChatAnalysisViewController.swift in Sources */, + 956C4D722E7690C600E32F93 /* ChatDetailViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -314,21 +324,14 @@ "$(inherited)", "-fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap", ); - OTHER_SWIFT_FLAGS = ( - "$(inherited)", - "-Xcc", - "-fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap", - ); + OTHER_SWIFT_FLAGS = "$(inherited) -Xcc -fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap"; PRODUCT_BUNDLE_IDENTIFIER = io.tuist.ChatTests; PRODUCT_NAME = ChatTests; SDKROOT = iphoneos; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ( - "$(inherited)", - DEBUG, - ); + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_COMPILATION_MODE = singlefile; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -413,11 +416,7 @@ "$(inherited)", "-fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap", ); - OTHER_SWIFT_FLAGS = ( - "$(inherited)", - "-Xcc", - "-fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap", - ); + OTHER_SWIFT_FLAGS = "$(inherited) -Xcc -fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap"; PRODUCT_BUNDLE_IDENTIFIER = io.tuist.Chat; PRODUCT_NAME = Chat; SDKROOT = iphoneos; @@ -511,11 +510,7 @@ "$(inherited)", "-fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap", ); - OTHER_SWIFT_FLAGS = ( - "$(inherited)", - "-Xcc", - "-fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap", - ); + OTHER_SWIFT_FLAGS = "$(inherited) -Xcc -fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap"; PRODUCT_BUNDLE_IDENTIFIER = io.tuist.ChatTests; PRODUCT_NAME = ChatTests; SDKROOT = iphoneos; @@ -553,11 +548,7 @@ "$(inherited)", "-fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap", ); - OTHER_SWIFT_FLAGS = ( - "$(inherited)", - "-Xcc", - "-fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap", - ); + OTHER_SWIFT_FLAGS = "$(inherited) -Xcc -fmodule-map-file=$(SRCROOT)/../../Tuist/.build/tuist-derived/RxCocoaRuntime/RxCocoaRuntime.modulemap"; PRODUCT_BUNDLE_IDENTIFIER = io.tuist.Chat; PRODUCT_NAME = Chat; SDKROOT = iphoneos; @@ -565,10 +556,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ( - "$(inherited)", - DEBUG, - ); + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_COMPILATION_MODE = singlefile; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; diff --git a/Projects/Chat/Sources/View/ChatAnalysisViewController.swift b/Projects/Chat/Sources/View/ChatAnalysisViewController.swift new file mode 100644 index 0000000..f9b8cbb --- /dev/null +++ b/Projects/Chat/Sources/View/ChatAnalysisViewController.swift @@ -0,0 +1,101 @@ +// +// ChatAnalysisViewController.swift +// Chat +// +// Created by 박지윤 on 9/9/25. +// + +import UIKit +import CommonUI +import Domain +import RxSwift + +public class ChatAnalysisViewController: BaseViewController { + let viewModel: ChatViewModel + private let chatAnalysisView = ChatAnalysisView() + let navigationBar = DefaultNavigationBar(leftImage: nil, + rightImage: CommonUIAssets.IconClose ?? nil, + title: "") + + private var chatDetail: ChatDetailVO + + public init(chatViewModel: ChatViewModel, chatDetail: ChatDetailVO) { + self.viewModel = chatViewModel + self.chatDetail = chatDetail + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + navigationBar.setupViewProperty(title: chatDetail.chatRoom.title) + chatAnalysisView.setupAnalysisData(chatDetail.chatList) + setupActions() + } + + public override func setupViewProperty() { + view.backgroundColor = CommonUIAssets.LMOrange4 + } + + public override func setupHierarchy() { + [navigationBar, chatAnalysisView] + .forEach { view.addSubview($0) } + } + + public override func setupLayout() { + navigationBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.width.centerX.equalToSuperview() + } + + chatAnalysisView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom) + $0.horizontalEdges.bottom.equalToSuperview() + } + } + + private func setupActions() { + // DefaultNavigationBar의 기본 타겟 제거 후 우리의 커스텀 액션 추가 + navigationBar.rightButton.removeTarget(navigationBar, action: #selector(DefaultNavigationBar.rightButtonTapped), for: .touchUpInside) + navigationBar.rightButton.addTarget(self, action: #selector(handleRightButtonTapped), for: .touchUpInside) + + // SaveButton 액션 설정 + chatAnalysisView.onSaveButtonTapped.subscribe(onNext: { [weak self] in + self?.handleSaveButtonTapped() + }).disposed(by: disposeBag) + } + + @objc private func handleRightButtonTapped() { + showExitConfirmationAlert() + } + + private func handleSaveButtonTapped() { + // 저장 버튼 클릭 시 루트 뷰컨트롤러로 이동 + navigationController?.popToRootViewController(animated: true) + } + + private func showExitConfirmationAlert() { + let lmAlert = LMAlert(title: "저장하지 않은 대화는 사라집니다.\n그래도 나가시겠습니까?") + + lmAlert.setCancelAction { + // 아니요 버튼 - 아무것도 하지 않음 + } + + lmAlert.setConfirmAction { [weak self] in + self?.deleteChatAndExit() + } + + lmAlert.show(in: view) + } + + private func deleteChatAndExit() { + // 대화방 삭제 API 호출 + viewModel.deleteChat() + + // 루트 뷰컨트롤러로 이동 + navigationController?.popToRootViewController(animated: true) + } +} diff --git a/Projects/Chat/Sources/View/ChatDetailViewController.swift b/Projects/Chat/Sources/View/ChatDetailViewController.swift new file mode 100644 index 0000000..ca8c226 --- /dev/null +++ b/Projects/Chat/Sources/View/ChatDetailViewController.swift @@ -0,0 +1,90 @@ +// +// ChatDetailViewController.swift +// Chat +// +// Created by 박지윤 on 1/1/25. +// + +import UIKit +import CommonUI +import Domain +import RxSwift + +public class ChatDetailViewController: BaseViewController { + + let viewModel: ChatViewModel + private let chatDetailView = ChatDetailView() + let navigationBar = DefaultNavigationBar(leftImage: CommonUIAssets.IconBack ?? nil, + rightImage: nil, + title: "") + + private var chatRoom: ChatRoomVO + private var chatDetail: ChatDetailVO? + + public init(chatViewModel: ChatViewModel, chatRoom: ChatRoomVO) { + self.viewModel = chatViewModel + self.chatRoom = chatRoom + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + navigationBar.setupViewProperty(title: chatRoom.title) + setupActions() + bindData() + loadChatDetail() + } + + public override func setupViewProperty() { + view.backgroundColor = CommonUIAssets.LMOrange4 + } + + public override func setupHierarchy() { + [navigationBar, chatDetailView] + .forEach { view.addSubview($0) } + } + + public override func setupLayout() { + navigationBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.width.centerX.equalToSuperview() + } + + chatDetailView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom) + $0.horizontalEdges.bottom.equalToSuperview() + } + } + + private func setupActions() { + // LeftButton 액션 (뒤로 가기) +// navigationBar.leftButton.removeTarget(navigationBar, action: #selector(DefaultNavigationBar.leftButtonTapped), for: .touchUpInside) +// navigationBar.leftButton.addTarget(self, action: #selector(handleBackButtonTapped), for: .touchUpInside) + } + + private func bindData() { + viewModel.chatDetailSubject + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] (chatDetail: ChatDetailVO) in + self?.updateChatDetail(chatDetail) + }) + .disposed(by: disposeBag) + } + + private func loadChatDetail() { + viewModel.getChatDetail(chatRoomId: chatRoom.chatRoomId) + } + + @objc private func handleBackButtonTapped() { + navigationController?.popViewController(animated: true) + } + + public func updateChatDetail(_ chatDetail: ChatDetailVO) { + self.chatDetail = chatDetail + chatDetailView.setupDetailData(chatDetail.chatList) + } +} diff --git a/Projects/Chat/Sources/View/ChatMainViewController.swift b/Projects/Chat/Sources/View/ChatMainViewController.swift new file mode 100644 index 0000000..7f12495 --- /dev/null +++ b/Projects/Chat/Sources/View/ChatMainViewController.swift @@ -0,0 +1,107 @@ +// +// ChatMainViewController.swift +// Chat +// +// Created by 박지윤 on 9/10/25. +// + +import UIKit +import CommonUI +import RxSwift +import Domain + +public class ChatMainViewController: BaseViewController { + let viewModel: ChatViewModel + public var onPresentNewChat: (() -> Void)? + + let chatLabel = UILabel().then { + $0.text = "대화" + $0.textColor = CommonUIAssets.LMBlack + $0.font = UIFont.systemFont(ofSize: 30, weight: .bold) + } + + let chatMainView = ChatMainView() + + 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) + viewModel.getChatList() + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupViewProperty() + setupHierarchy() + setupLayout() + bindData() + bindEvents() + } + + public override func setupViewProperty() { + view.backgroundColor = CommonUIAssets.LMOrange4 + } + + public override func setupHierarchy() { + [chatLabel, chatMainView].forEach { view.addSubview($0) } + } + + public override func setupDelegate() { + } + + public override func setupLayout() { + chatLabel.snp.makeConstraints { + $0.height.equalTo(34) + $0.top.equalTo(view.safeAreaLayoutGuide).offset(10) + $0.leading.equalToSuperview().inset(20) + } + + chatMainView.snp.makeConstraints { + $0.top.equalTo(chatLabel.snp.bottom).offset(10) + $0.horizontalEdges.bottom.equalToSuperview() + } + } + + private func bindData() { + viewModel.chatListSubject + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] chatRoomList in + self?.chatMainView.updateChatList(chatRoomList) + }) + .disposed(by: disposeBag) + } + + private func bindEvents() { + chatMainView.newChatButtonTapped + .bind { [weak self] in + self?.presentNewChatView() + } + .disposed(by: disposeBag) + + chatMainView.chatCellTapped + .bind { [weak self] chatRoom in + self?.presentChatDetailView(chatRoom: chatRoom) + } + .disposed(by: disposeBag) + } + + private func presentNewChatView() { + let chatViewController = ChatViewController(chatViewModel: viewModel) + chatViewController.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(chatViewController, animated: true) + } + + private func presentChatDetailView(chatRoom: ChatRoomVO) { + let chatDetailViewController = ChatDetailViewController(chatViewModel: viewModel, chatRoom: chatRoom) + chatDetailViewController.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(chatDetailViewController, animated: true) + } +} diff --git a/Projects/Chat/Sources/View/ChatViewController.swift b/Projects/Chat/Sources/View/ChatViewController.swift index 5811540..0ff6c64 100644 --- a/Projects/Chat/Sources/View/ChatViewController.swift +++ b/Projects/Chat/Sources/View/ChatViewController.swift @@ -14,7 +14,14 @@ public class ChatViewController: BaseViewController { let viewModel: ChatViewModel let chatView = ChatView() + let navigationBar = DefaultNavigationBar(leftImage: CommonUIAssets.IconBack ?? nil, + rightImage: nil, + title: nil) + private var messages: [ChatMessageVO] = [] + private var hasStartedConversation = false + + private let loadingView = ChatAnalysisLoadingView() public init(chatViewModel: ChatViewModel) { self.viewModel = chatViewModel @@ -39,6 +46,9 @@ public class ChatViewController: BaseViewController { bindActions() setupTextFieldActions() viewModel.startTextChat() + + // 초기에 endButton 숨기기 + chatView.hideEndButton() } public override func setupViewProperty() { @@ -46,16 +56,30 @@ public class ChatViewController: BaseViewController { } public override func setupHierarchy() { - view.addSubview(chatView) + [navigationBar, chatView, loadingView] + .forEach { view.addSubview($0) } } public override func setupDelegate() { } public override func setupLayout() { + navigationBar.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.width.centerX.equalToSuperview() + } + chatView.snp.makeConstraints { + $0.top.equalTo(navigationBar.snp.bottom) + $0.horizontalEdges.bottom.equalToSuperview() + } + + loadingView.snp.makeConstraints { $0.edges.equalToSuperview() } + + navigationBar.isHidden = false + loadingView.isHidden = true } private func bindData() { @@ -74,12 +98,28 @@ public class ChatViewController: BaseViewController { self?.addMessageToUI(message) }) .disposed(by: disposeBag) + + viewModel.analysisResultSubject + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] analysisResult in + self?.presentAnalysisController(with: analysisResult) + }) + .disposed(by: disposeBag) } private func bindActions() { chatView.onSendButtonTapped = { [weak self] message in self?.sendMessage(message) } + + chatView.onEndButtonTapped = { [weak self] in + self?.showAnalysisLoading() + self?.viewModel.postChatAnalysis() + } + + // DefaultNavigationBar의 기본 타겟 제거 후 우리의 커스텀 액션 추가 + navigationBar.leftButton.removeTarget(navigationBar, action: #selector(DefaultNavigationBar.leftButtonTapped), for: .touchUpInside) + navigationBar.leftButton.addTarget(self, action: #selector(handleBackButtonTapped), for: .touchUpInside) } private func setupTextFieldActions() { @@ -92,18 +132,21 @@ public class ChatViewController: BaseViewController { } private func sendMessage(_ text: String) { - // 첫 번째 메시지 전송 시 추천 섹션 숨기기 + // 첫 번째 메시지 전송 시 추천 섹션 숨기기 및 endButton 보이기 if messages.isEmpty { + hasStartedConversation = true chatView.hideRecommendSection() + chatView.showEndButton() + chatView.updateSubtitleText("대화를 종료하면 분석 결과를 제공해요") } - + // 사용자 메시지 UI에 추가 let userMessage = ChatMessageVO(chatId: 0, author: "HUMAN", content: text) addMessageToUI(userMessage) // API 호출 viewModel.sendMessage(content: text) - + // 텍스트 필드 초기화 및 버튼 비활성화 chatView.chatTextField.text = "" chatView.sendButton.isEnabled = false @@ -113,6 +156,46 @@ public class ChatViewController: BaseViewController { messages.append(message) chatView.addMessageToUI(message) } + + private func showAnalysisLoading() { + print("🔄 대화 분석 시작") + + // 로딩 화면 표시 + loadingView.isHidden = false + loadingView.alpha = 0 + navigationBar.isHidden = true + + UIView.animate(withDuration: 0.3) { + self.loadingView.alpha = 1 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + self.hideAnalysisLoading() + } + } + + private func hideAnalysisLoading() { + UIView.animate(withDuration: 0.3, animations: { + self.loadingView.alpha = 0 + }) { _ in + self.loadingView.isHidden = true + } + } + + private func navigateToAnalysisResult() { + print("📊 분석 결과 화면으로 이동") + + // 임시 ChatDetailVO 생성 (빈 데이터로) + let emptyChatDetail = ChatDetailVO( + chatRoom: ChatRoomVO(chatRoomId: 0, title: "대화 분석", createdAt: ""), + chatList: [] + ) + let analysisController = ChatAnalysisViewController(chatViewModel: viewModel, + chatDetail: emptyChatDetail) + analysisController.modalPresentationStyle = UIModalPresentationStyle.fullScreen + + present(analysisController, animated: true) + } private func updateRecommendTopics(_ topics: [String]) { print("🔄 추천 주제 업데이트: \(topics)") @@ -122,9 +205,46 @@ public class ChatViewController: BaseViewController { return } - // ChatView의 recommendTexts 프로퍼티로 간단하게 업데이트 chatView.recommendTexts = topics print("✅ 추천 주제 업데이트 완료: \(topics.count)개") } + + private func presentAnalysisController(with analysisResult: ChatDetailVO) { + let analysisController = ChatAnalysisViewController(chatViewModel: viewModel, + chatDetail: analysisResult) + analysisController.hidesBottomBarWhenPushed = true + navigationController?.pushViewController(analysisController, animated: true) + } + + @objc private func handleBackButtonTapped() { + if hasStartedConversation { + showEndConversationAlert() + } else { + // 대화가 시작되지 않았다면 바로 뒤로 가기 + navigationController?.popViewController(animated: true) + } + } + + private func showEndConversationAlert() { + let lmAlert = LMAlert(title: "대화를 종료하시겠습니까?") + + lmAlert.setCancelAction { + // 아니요 버튼 - 아무것도 하지 않음 + } + + lmAlert.setConfirmAction { [weak self] in + self?.endConversationAndGoBack() + } + + lmAlert.show(in: view) + } + + private func endConversationAndGoBack() { + // 대화방 삭제 API 호출 + viewModel.deleteChat() + + // 이전 화면으로 이동 + navigationController?.popViewController(animated: true) + } } diff --git a/Projects/Chat/Sources/ViewModel/ChatViewModel.swift b/Projects/Chat/Sources/ViewModel/ChatViewModel.swift index c9000c7..b476ab4 100644 --- a/Projects/Chat/Sources/ViewModel/ChatViewModel.swift +++ b/Projects/Chat/Sources/ViewModel/ChatViewModel.swift @@ -18,8 +18,11 @@ public class ChatViewModel: ChatViewModelProtocol { private let chatUseCase: ChatUseCase private let tokenUseCase: TokenUseCase + let chatListSubject = PublishSubject() let chatSubject = PublishSubject() let messageSubject = PublishSubject() + let analysisResultSubject = PublishSubject() + let chatDetailSubject = PublishSubject() private var currentChatRoomId: Int = 0 public init(chatUseCase: ChatUseCase, @@ -28,6 +31,16 @@ public class ChatViewModel: ChatViewModelProtocol { self.tokenUseCase = tokenUseCase } + func getChatList() { + chatUseCase.getChatList() + .subscribe(onSuccess: { [weak self] chat in + print("✅ 저장된 대화 불러오기 성공: \(chat)") + self?.chatListSubject.onNext(chat) + }, onFailure: { error in + print("❌ 저장된 대화 불러오기 실패: \(error)") + }).disposed(by: disposeBag) + } + func startTextChat() { chatUseCase.postChatStart() .subscribe(onSuccess: { [weak self] chat in @@ -53,4 +66,33 @@ public class ChatViewModel: ChatViewModelProtocol { print("❌ 메시지 전송 실패: \(error)") }).disposed(by: disposeBag) } + + func postChatAnalysis() { + chatUseCase.postChatAnalysis(chatRoomId: currentChatRoomId) + .subscribe(onSuccess: { [weak self] analysisResult in + print("✅ 대화 분석 성공: \(analysisResult)") + self?.analysisResultSubject.onNext(analysisResult) + }, onFailure: { error in + print("❌ 대화 분석 실패: \(error)") + }).disposed(by: disposeBag) + } + + func deleteChat() { + chatUseCase.deleteChat(chatRoomId: currentChatRoomId) + .subscribe(onSuccess: { result in + print("✅ 대화방 삭제 성공: \(result)") + }, onFailure: { error in + print("❌ 대화방 삭제 실패: \(error)") + }).disposed(by: disposeBag) + } + + func getChatDetail(chatRoomId: Int) { + chatUseCase.getChatDetail(chatRoomId: chatRoomId) + .subscribe(onSuccess: { [weak self] chatDetail in + print("✅ 대화 상세 조회 성공: \(chatDetail)") + self?.chatDetailSubject.onNext(chatDetail) + }, 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 473999c..5e9dac9 100644 --- a/Projects/CommonUI/CommonUI.xcodeproj/project.pbxproj +++ b/Projects/CommonUI/CommonUI.xcodeproj/project.pbxproj @@ -25,6 +25,12 @@ 950A0D602E5C3C7000C07CF2 /* LMButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950A0D5F2E5C3C6D00C07CF2 /* LMButton.swift */; }; 950A0D622E5C562700C07CF2 /* LMInputField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950A0D612E5C561400C07CF2 /* LMInputField.swift */; }; 950A0D962E605CEA00C07CF2 /* UIStackView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950A0D952E605CE300C07CF2 /* UIStackView+Extension.swift */; }; + 9516ED4A2E7076FF00F548A1 /* ChatAnalysisView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9516ED492E7076FB00F548A1 /* ChatAnalysisView.swift */; }; + 9516ED4D2E707ACF00F548A1 /* ChatAnalysisLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9516ED4C2E707AC900F548A1 /* ChatAnalysisLoadingView.swift */; }; + 9516ED512E708CF700F548A1 /* ChatMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9516ED502E708CF200F548A1 /* ChatMainView.swift */; }; + 9516ED532E709BA600F548A1 /* ChatListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9516ED522E709BA300F548A1 /* ChatListCell.swift */; }; + 956C4D702E767F8800E32F93 /* LMAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956C4D6F2E767F8300E32F93 /* LMAlert.swift */; }; + 956C4D742E7690EA00E32F93 /* ChatDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956C4D732E7690E600E32F93 /* ChatDetailView.swift */; }; BAD8B768F782046D4AA1C073 /* RxCocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F3DE048BEDCB92B48F401061 /* RxCocoa.framework */; }; BE81B1F3E60D37D75A058D2B /* SnapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9CCDC15081A22BAED6318E3E /* SnapKit.framework */; }; C2B0F8237715D14D8797DBC9 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 012F45F908769FA7C3C0792F /* LoginView.swift */; }; @@ -76,6 +82,12 @@ 950A0D5F2E5C3C6D00C07CF2 /* LMButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LMButton.swift; sourceTree = ""; }; 950A0D612E5C561400C07CF2 /* LMInputField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LMInputField.swift; sourceTree = ""; }; 950A0D952E605CE300C07CF2 /* UIStackView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIStackView+Extension.swift"; sourceTree = ""; }; + 9516ED492E7076FB00F548A1 /* ChatAnalysisView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAnalysisView.swift; sourceTree = ""; }; + 9516ED4C2E707AC900F548A1 /* ChatAnalysisLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAnalysisLoadingView.swift; sourceTree = ""; }; + 9516ED502E708CF200F548A1 /* ChatMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMainView.swift; sourceTree = ""; }; + 9516ED522E709BA300F548A1 /* ChatListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListCell.swift; sourceTree = ""; }; + 956C4D6F2E767F8300E32F93 /* LMAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LMAlert.swift; sourceTree = ""; }; + 956C4D732E7690E600E32F93 /* ChatDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDetailView.swift; sourceTree = ""; }; 9CCDC15081A22BAED6318E3E /* SnapKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SnapKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BACC7259FC0C14CB352A4E6B /* OptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionView.swift; sourceTree = ""; }; C28FE6392E1612667826E5C5 /* DiaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiaryView.swift; sourceTree = ""; }; @@ -179,6 +191,7 @@ 950A0D522E5C296400C07CF2 /* Component */ = { isa = PBXGroup; children = ( + 956C4D6F2E767F8300E32F93 /* LMAlert.swift */, 950A0D552E5C29CC00C07CF2 /* LMTextField.swift */, 950A0D5F2E5C3C6D00C07CF2 /* LMButton.swift */, 950A0D612E5C561400C07CF2 /* LMInputField.swift */, @@ -197,7 +210,12 @@ 951F3F852E6DDE7F0022583B /* Chat */ = { isa = PBXGroup; children = ( + 9516ED502E708CF200F548A1 /* ChatMainView.swift */, + 9516ED522E709BA300F548A1 /* ChatListCell.swift */, 3EBDD10D8389EBB23B42C54F /* ChatView.swift */, + 956C4D732E7690E600E32F93 /* ChatDetailView.swift */, + 9516ED4C2E707AC900F548A1 /* ChatAnalysisLoadingView.swift */, + 9516ED492E7076FB00F548A1 /* ChatAnalysisView.swift */, ); path = Chat; sourceTree = ""; @@ -350,6 +368,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9516ED4A2E7076FF00F548A1 /* ChatAnalysisView.swift in Sources */, 950A0D602E5C3C7000C07CF2 /* LMButton.swift in Sources */, C94951E661D9DC9D28B56273 /* TuistAssets+CommonUI.swift in Sources */, 7DC59B80630854028C7C80F4 /* TuistBundle+CommonUI.swift in Sources */, @@ -358,18 +377,23 @@ 950A0D962E605CEA00C07CF2 /* UIStackView+Extension.swift in Sources */, E055AA66777B1D4CC8C884E4 /* CommonUIAssets.swift in Sources */, 950A0D4F2E5AADB500C07CF2 /* SignUpView.swift in Sources */, + 956C4D742E7690EA00E32F93 /* ChatDetailView.swift in Sources */, F7673E4248628D67F3542848 /* ChatView.swift in Sources */, 950A0D562E5C29D000C07CF2 /* LMTextField.swift in Sources */, + 9516ED4D2E707ACF00F548A1 /* ChatAnalysisLoadingView.swift in Sources */, FB3FE0AB8AE6868B6D5E241C /* DiaryView.swift in Sources */, 70139D721530B3262C44ABC1 /* AnswerView.swift in Sources */, 416C58AEE5E4491991982CFF /* HomeProgressView.swift in Sources */, 7D319882A302F75CCE46A48C /* HomeQuizView.swift in Sources */, 537F80B2F39FD73F6F78F9B2 /* HomeView.swift in Sources */, + 9516ED532E709BA600F548A1 /* ChatListCell.swift in Sources */, 950A0D622E5C562700C07CF2 /* LMInputField.swift in Sources */, + 9516ED512E708CF700F548A1 /* ChatMainView.swift in Sources */, 65762CE867888754D56BA2CB /* OptionView.swift in Sources */, CEADBDD98AC9921C05AAC1DA /* QuizCollectionViewCell.swift in Sources */, 950A0D512E5AADC400C07CF2 /* SignInView.swift in Sources */, 592FAEA836FD6B4B3F466D72 /* QuizCompleteAlertView.swift in Sources */, + 956C4D702E767F8800E32F93 /* LMAlert.swift in Sources */, 65B3402D15A9F06B3E88CE19 /* QuizView.swift in Sources */, C2B0F8237715D14D8797DBC9 /* LoginView.swift in Sources */, 473C96577A92DD6F71241A76 /* MyPageView.swift in Sources */, diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/bubble.imageset/Contents.json b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/bubble.imageset/Contents.json new file mode 100644 index 0000000..c8ed7b4 --- /dev/null +++ b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/bubble.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bubble@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "bubble@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/bubble.imageset/bubble@2x.png b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/bubble.imageset/bubble@2x.png new file mode 100644 index 0000000..9493cd5 Binary files /dev/null and b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/bubble.imageset/bubble@2x.png differ diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/bubble.imageset/bubble@3x.png b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/bubble.imageset/bubble@3x.png new file mode 100644 index 0000000..b5efe52 Binary files /dev/null and b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/bubble.imageset/bubble@3x.png differ diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/close.imageset/Contents.json b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/close.imageset/Contents.json new file mode 100644 index 0000000..a52e16e --- /dev/null +++ b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/close.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "close@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "close@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/close.imageset/close@2x.png b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/close.imageset/close@2x.png new file mode 100644 index 0000000..c8dd74c Binary files /dev/null and b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/close.imageset/close@2x.png differ diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/close.imageset/close@3x.png b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/close.imageset/close@3x.png new file mode 100644 index 0000000..f690a76 Binary files /dev/null and b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/close.imageset/close@3x.png differ diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/edit.imageset/Contents.json b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/edit.imageset/Contents.json new file mode 100644 index 0000000..b1c8a4f --- /dev/null +++ b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/edit.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "edit@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "edit@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/edit.imageset/edit@2x.png b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/edit.imageset/edit@2x.png new file mode 100644 index 0000000..a3eba7a Binary files /dev/null and b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/edit.imageset/edit@2x.png differ diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/edit.imageset/edit@3x.png b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/edit.imageset/edit@3x.png new file mode 100644 index 0000000..6e96b93 Binary files /dev/null and b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/edit.imageset/edit@3x.png differ diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/message.imageset/Contents.json b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/message.imageset/Contents.json new file mode 100644 index 0000000..d72b03f --- /dev/null +++ b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/message.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "message@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "message@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/message.imageset/message@2x.png b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/message.imageset/message@2x.png new file mode 100644 index 0000000..55cde16 Binary files /dev/null and b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/message.imageset/message@2x.png differ diff --git a/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/message.imageset/message@3x.png b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/message.imageset/message@3x.png new file mode 100644 index 0000000..58504a5 Binary files /dev/null and b/Projects/CommonUI/Sources/Assets/Images.xcassets/Icon/message.imageset/message@3x.png differ diff --git a/Projects/CommonUI/Sources/Component/LMAlert.swift b/Projects/CommonUI/Sources/Component/LMAlert.swift new file mode 100644 index 0000000..44a1e8c --- /dev/null +++ b/Projects/CommonUI/Sources/Component/LMAlert.swift @@ -0,0 +1,177 @@ +// +// LMAlert.swift +// CommonUI +// +// Created by 박지윤 on 9/14/25. +// + +import UIKit +import SnapKit +import Then + +public class LMAlert: UIView { + + // MARK: - UI Components + private let backgroundView = UIView().then { + $0.backgroundColor = UIColor.black.withAlphaComponent(0.5) + $0.alpha = 0 + } + + private let containerView = UIView().then { + $0.backgroundColor = UIColor.white + $0.layer.cornerRadius = 16 + $0.layer.masksToBounds = true + } + + private let titleLabel = UILabel().then { + $0.font = .systemFont(ofSize: 18, weight: .semibold) + $0.textColor = .black + $0.textAlignment = .center + $0.numberOfLines = 0 + } + + private let buttonStackView = UIStackView().then { + $0.axis = .horizontal + $0.distribution = .fillEqually + $0.spacing = 12 + } + + private let cancelButton = UIButton().then { + $0.backgroundColor = .white + $0.layer.borderColor = UIColor.lightGray.cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 8 + $0.setTitleColor(.black, for: .normal) + $0.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + $0.setTitle("아니요", for: .normal) + } + + private let confirmButton = UIButton().then { + $0.backgroundColor = UIColor(red: 1.0, green: 0.8, blue: 0.6, alpha: 1.0) // 연한 주황색 + $0.layer.cornerRadius = 8 + $0.setTitleColor(.black, for: .normal) + $0.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + $0.setTitle("네", for: .normal) + } + + // MARK: - Properties + private var cancelAction: (() -> Void)? + private var confirmAction: (() -> Void)? + + // MARK: - Initialization + public init(title: String, cancelTitle: String = "아니요", confirmTitle: String = "네") { + super.init(frame: .zero) + setupUI() + setupLayout() + configure(title: title, cancelTitle: cancelTitle, confirmTitle: confirmTitle) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + private func setupUI() { + addSubview(backgroundView) + addSubview(containerView) + + containerView.addSubview(titleLabel) + containerView.addSubview(buttonStackView) + + buttonStackView.addArrangedSubview(cancelButton) + buttonStackView.addArrangedSubview(confirmButton) + + // 버튼 액션 설정 + cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(confirmButtonTapped), for: .touchUpInside) + + // 배경 탭 제스처 + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped)) + backgroundView.addGestureRecognizer(tapGesture) + } + + private func setupLayout() { + backgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + containerView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.width.equalTo(280) + $0.height.equalTo(140) + } + + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(24) + $0.horizontalEdges.equalToSuperview().inset(20) + } + + buttonStackView.snp.makeConstraints { + $0.bottom.equalToSuperview().inset(20) + $0.horizontalEdges.equalToSuperview().inset(20) + $0.height.equalTo(44) + } + } + + private func configure(title: String, cancelTitle: String, confirmTitle: String) { + titleLabel.text = title + cancelButton.setTitle(cancelTitle, for: .normal) + confirmButton.setTitle(confirmTitle, for: .normal) + } + + // MARK: - Actions + @objc private func cancelButtonTapped() { + hide { + self.cancelAction?() + } + } + + @objc private func confirmButtonTapped() { + hide { + self.confirmAction?() + } + } + + @objc private func backgroundTapped() { + hide { + self.cancelAction?() + } + } + + // MARK: - Public Methods + public func setCancelAction(_ action: @escaping () -> Void) { + self.cancelAction = action + } + + public func setConfirmAction(_ action: @escaping () -> Void) { + self.confirmAction = action + } + + public func show(in view: UIView) { + view.addSubview(self) + self.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + // 애니메이션으로 나타나기 + containerView.transform = CGAffineTransform(scaleX: 0.8, y: 0.8) + containerView.alpha = 0 + + UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: .curveEaseOut) { + self.backgroundView.alpha = 1 + self.containerView.alpha = 1 + self.containerView.transform = .identity + } + } + + private func hide(completion: @escaping () -> Void) { + UIView.animate(withDuration: 0.2, animations: { + self.backgroundView.alpha = 0 + self.containerView.alpha = 0 + self.containerView.transform = CGAffineTransform(scaleX: 0.8, y: 0.8) + }) { _ in + self.removeFromSuperview() + completion() + } + } +} diff --git a/Projects/CommonUI/Sources/Enum/CommonUIAssets.swift b/Projects/CommonUI/Sources/Enum/CommonUIAssets.swift index 9447024..380317e 100644 --- a/Projects/CommonUI/Sources/Enum/CommonUIAssets.swift +++ b/Projects/CommonUI/Sources/Enum/CommonUIAssets.swift @@ -49,7 +49,11 @@ public enum CommonUIAssets { /// * Icon public static let IconPlay = image(named: "play") public static let IconBack = image(named: "back") + public static let IconClose = image(named: "close") public static let IconSend = image(named: "send") + public static let IconEdit = image(named: "edit") + public static let IconMessage = image(named: "message") + public static let IconBubble = image(named: "bubble") /// color public static let LMOrange1 = color(named: "LMOrange01") diff --git a/Projects/CommonUI/Sources/View/Chat/ChatAnalysisLoadingView.swift b/Projects/CommonUI/Sources/View/Chat/ChatAnalysisLoadingView.swift new file mode 100644 index 0000000..560f6dc --- /dev/null +++ b/Projects/CommonUI/Sources/View/Chat/ChatAnalysisLoadingView.swift @@ -0,0 +1,61 @@ +// +// ChatAnalysisLoadingView.swift +// CommonUI +// +// Created by 박지윤 on 9/10/25. +// + +import UIKit +import SnapKit +import Then + +open class ChatAnalysisLoadingView: UIView { + private let loadingSpinner = UIActivityIndicatorView(style: .large).then { + $0.color = CommonUIAssets.LMGray1 + $0.startAnimating() + } + + private let mainLabel = UILabel().then { + $0.text = "사용자 대화를 분석 중입니다..." + $0.textColor = CommonUIAssets.LMGray1 + $0.font = UIFont.systemFont(ofSize: 18, weight: .medium) + $0.textAlignment = .center + } + + private let subLabel = UILabel().then { + $0.text = "잠시만 기다려 주세요" + $0.textColor = CommonUIAssets.LMGray3 + $0.font = UIFont.systemFont(ofSize: 14, weight: .regular) + $0.textAlignment = .center + } + + public override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + backgroundColor = CommonUIAssets.LMOrange4 + + [loadingSpinner, mainLabel, subLabel].forEach { addSubview($0) } + + loadingSpinner.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.centerY.equalToSuperview().offset(-40) + } + + mainLabel.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.top.equalTo(loadingSpinner.snp.bottom).offset(20) + } + + subLabel.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.top.equalTo(mainLabel.snp.bottom).offset(8) + } + } +} diff --git a/Projects/CommonUI/Sources/View/Chat/ChatAnalysisView.swift b/Projects/CommonUI/Sources/View/Chat/ChatAnalysisView.swift new file mode 100644 index 0000000..9409bbd --- /dev/null +++ b/Projects/CommonUI/Sources/View/Chat/ChatAnalysisView.swift @@ -0,0 +1,245 @@ +// +// ChatAnalysisView.swift +// CommonUI +// +// Created by 박지윤 on 9/9/25. +// + +import UIKit +import SnapKit +import Then +import Domain +import RxSwift +import RxRelay + +open class ChatAnalysisView: UIView { + + public var onSaveButtonTapped = PublishRelay() + let disposeBag = DisposeBag() + + private let scrollView = UIScrollView().then { + $0.showsVerticalScrollIndicator = true + $0.alwaysBounceVertical = true + } + + private let contentView = UIView() + + private let analysisStackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 16 + $0.alignment = .fill + } + + private let nonScrollView = UIView().then { + $0.backgroundColor = CommonUIAssets.LMOrange4 + } + + var chatSaveButton = LMButton(textColor: CommonUIAssets.LMBlack, + bgColor: CommonUIAssets.LMOrange1) + public override init(frame: CGRect) { + super.init(frame: frame) + initAttribute() + setupUI() + setupConstraints() + bindEvents() + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func initAttribute() { + chatSaveButton = chatSaveButton.then { + $0.setTitle("대화 저장하기", for: .normal) + } + } + + private func setupUI() { + backgroundColor = CommonUIAssets.LMOrange4 + + [scrollView, nonScrollView].forEach { addSubview($0) } + scrollView.addSubview(contentView) + contentView.addSubview(analysisStackView) + nonScrollView.addSubview(chatSaveButton) + } + + public func setupConstraints() { + scrollView.snp.makeConstraints { + $0.top.horizontalEdges.equalToSuperview() + $0.bottom.equalTo(nonScrollView.snp.top) + } + + contentView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.width.equalToSuperview() + } + + analysisStackView.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalToSuperview().offset(-20) + } + + nonScrollView.snp.makeConstraints { + $0.width.equalToSuperview() + $0.bottom.equalTo(self.safeAreaLayoutGuide).offset(-20) + $0.height.equalTo(75) + } + + chatSaveButton.snp.makeConstraints { + $0.center.equalToSuperview() + $0.width.equalToSuperview().inset(20) + } + } + + func bindEvents() { + chatSaveButton.rx.tap + .bind(to: onSaveButtonTapped) + .disposed(by: disposeBag) + } + + public func setupAnalysisData(_ messages: [ChatListVO]) { + analysisStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + for message in messages { + let analysisView = createAnalysisItemView(from: message) + analysisStackView.addArrangedSubview(analysisView) + } + } + + private func createAnalysisItemView(from message: ChatListVO) -> UIView { + let containerView = UIView() + + // 메시지 뷰 + let author = message.author == 1 ? "HUMAN" : "AI" + let messageView = createMessageBubble(message.content, author: author) + containerView.addSubview(messageView) + + messageView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + } + + // comment가 있는 경우 피드백 뷰 추가 (사용자 메시지에만) + if let comment = message.comment, !comment.isEmpty, message.author == 1 { + let feedback = Feedback( + text: comment, + suggestion: nil + ) + let feedbackView = createFeedbackBubble(feedback) + containerView.addSubview(feedbackView) + + feedbackView.snp.makeConstraints { + $0.top.equalTo(messageView.snp.bottom).offset(8) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + } + } else { + messageView.snp.makeConstraints { + $0.bottom.equalToSuperview() + } + } + + return containerView + } + + private func createMessageBubble(_ text: String, author: String) -> UIView { + let containerView = UIView() + + let bubbleView = UIView().then { + $0.layer.cornerRadius = 16 + $0.backgroundColor = author == "HUMAN" ? CommonUIAssets.LMBlue2 : UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1.0) + } + + let messageLabel = UILabel().then { + $0.text = text + $0.textColor = author == "HUMAN" ? .white : .black + $0.font = .systemFont(ofSize: 16, weight: .regular) + $0.numberOfLines = 0 + } + + containerView.addSubview(bubbleView) + bubbleView.addSubview(messageLabel) + + // 메시지 정렬 (사용자는 오른쪽, AI는 왼쪽) + if 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 + } + + private func createFeedbackBubble(_ feedback: Feedback) -> UIView { + let containerView = UIView() + + let bubbleView = UIView().then { + $0.layer.cornerRadius = 16 + $0.backgroundColor = UIColor(red: 0.9, green: 0.95, blue: 1.0, alpha: 1.0) // 연한 파란색 + } + + let feedbackLabel = UILabel().then { + $0.text = feedback.text + $0.textColor = .black + $0.font = .systemFont(ofSize: 14, weight: .regular) + $0.numberOfLines = 0 + } + + containerView.addSubview(bubbleView) + bubbleView.addSubview(feedbackLabel) + + // 피드백은 오른쪽 정렬 + bubbleView.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.trailing.equalToSuperview() + $0.width.lessThanOrEqualTo(280) + } + + feedbackLabel.snp.makeConstraints { + $0.edges.equalToSuperview().inset(12) + } + + // 제안이 있는 경우 추가 + if let suggestion = feedback.suggestion { + let suggestionLabel = UILabel().then { + $0.text = suggestion + $0.textColor = .black + $0.font = .systemFont(ofSize: 13, weight: .regular) + $0.numberOfLines = 0 + } + + bubbleView.addSubview(suggestionLabel) + + suggestionLabel.snp.makeConstraints { + $0.leading.trailing.equalTo(feedbackLabel) + $0.top.equalTo(feedbackLabel.snp.bottom).offset(8) + $0.bottom.equalToSuperview().offset(-12) + } + } + + return containerView + } +} + +struct AnalysisItem { + let message: String + let author: String + let feedback: Feedback? +} + +struct Feedback { + let text: String + let suggestion: String? +} diff --git a/Projects/CommonUI/Sources/View/Chat/ChatDetailView.swift b/Projects/CommonUI/Sources/View/Chat/ChatDetailView.swift new file mode 100644 index 0000000..feebeae --- /dev/null +++ b/Projects/CommonUI/Sources/View/Chat/ChatDetailView.swift @@ -0,0 +1,190 @@ +// +// ChatDetailView.swift +// CommonUI +// +// Created by 박지윤 on 1/1/25. +// + +import UIKit +import SnapKit +import Then +import Domain +import RxSwift +import RxRelay + +open class ChatDetailView: UIView { + + let disposeBag = DisposeBag() + + private let scrollView = UIScrollView().then { + $0.showsVerticalScrollIndicator = true + $0.alwaysBounceVertical = true + } + + private let contentView = UIView() + + private let analysisStackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 16 + $0.alignment = .fill + } + + public override init(frame: CGRect) { + super.init(frame: frame) + initAttribute() + setupUI() + setupConstraints() + bindEvents() + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func initAttribute() { + + } + + private func setupUI() { + backgroundColor = CommonUIAssets.LMOrange4 + + [scrollView].forEach { addSubview($0) } + [contentView].forEach { scrollView.addSubview($0) } + [analysisStackView].forEach { contentView.addSubview($0) } + } + + private func setupConstraints() { + scrollView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + contentView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.width.equalToSuperview() + } + + analysisStackView.snp.makeConstraints { + $0.edges.equalToSuperview().inset(20) + } + } + + private func bindEvents() { + // 저장 버튼 제거로 인한 이벤트 바인딩 제거 + } + + public func setupDetailData(_ chatList: [ChatListVO]) { + // 기존 뷰들 제거 + analysisStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + // 각 메시지를 UI로 변환 + for (index, chat) in chatList.enumerated() { + let messageView = createMessageView(chat: chat, index: index) + analysisStackView.addArrangedSubview(messageView) + } + + // 마지막에 여백 추가 + let spacerView = UIView() + spacerView.snp.makeConstraints { $0.height.equalTo(20) } + analysisStackView.addArrangedSubview(spacerView) + } + + private func createMessageView(chat: ChatListVO, index: Int) -> UIView { + let containerView = UIView() + + // 말풍선 배경 + let bubbleView = UIView().then { + $0.backgroundColor = chat.author == 0 ? UIColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1.0) : CommonUIAssets.LMBlue2 + $0.layer.cornerRadius = 16 + } + + // 메시지 내용 + let contentLabel = UILabel().then { + $0.font = .systemFont(ofSize: 16, weight: .regular) + $0.textColor = chat.author == 0 ? .black : .white + $0.text = chat.content + $0.numberOfLines = 0 + } + + containerView.addSubview(bubbleView) + bubbleView.addSubview(contentLabel) + + // 메시지 정렬 (사용자는 오른쪽, AI는 왼쪽) + if chat.author == 0 { + // AI 메시지 (왼쪽 정렬) + bubbleView.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.leading.equalToSuperview() + $0.width.lessThanOrEqualTo(250) + } + } else { + // 사용자 메시지 (오른쪽 정렬) + bubbleView.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.trailing.equalToSuperview() + $0.width.lessThanOrEqualTo(250) + } + } + + contentLabel.snp.makeConstraints { + $0.edges.equalToSuperview().inset(12) + } + + // 사용자 메시지에 comment 추가 + if chat.author != 0, let comment = chat.comment, !comment.isEmpty { + let commentView = createCommentView(comment: comment) + containerView.addSubview(commentView) + + commentView.snp.makeConstraints { + $0.top.equalTo(bubbleView.snp.bottom).offset(8) + $0.trailing.equalToSuperview() + $0.width.lessThanOrEqualTo(250) + $0.bottom.equalToSuperview() + } + + // bubbleView의 bottom constraint 수정 + bubbleView.snp.remakeConstraints { + $0.top.equalToSuperview() + $0.trailing.equalToSuperview() + $0.width.lessThanOrEqualTo(250) + $0.bottom.equalTo(commentView.snp.top).offset(-8) + } + } + + return containerView + } + + private func createCommentView(comment: String) -> UIView { + let commentContainer = UIView().then { + $0.backgroundColor = UIColor.systemGray6 + $0.layer.cornerRadius = 12 + } + + let commentLabel = UILabel().then { + $0.font = .systemFont(ofSize: 14, weight: .regular) + $0.textColor = .darkGray + $0.text = comment + $0.numberOfLines = 0 + } + + commentContainer.addSubview(commentLabel) + + commentLabel.snp.makeConstraints { + $0.edges.equalToSuperview().inset(12) + } + + return commentContainer + } + + private func formatDate(_ dateString: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + + if let date = formatter.date(from: dateString) { + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "yyyy.MM.dd HH:mm" + return displayFormatter.string(from: date) + } + + return dateString + } +} diff --git a/Projects/CommonUI/Sources/View/Chat/ChatListCell.swift b/Projects/CommonUI/Sources/View/Chat/ChatListCell.swift new file mode 100644 index 0000000..4252fe1 --- /dev/null +++ b/Projects/CommonUI/Sources/View/Chat/ChatListCell.swift @@ -0,0 +1,68 @@ +// +// ChatListCell.swift +// CommonUI +// +// Created by 박지윤 on 9/10/25. +// + +import UIKit + +class ChatListCell: UITableViewCell { + + private let profileIconView = UIImageView().then { + $0.image = CommonUIAssets.IconBubble + } + + private let labelStackView = UIStackView().then { + $0.axis = .vertical + $0.alignment = .leading + $0.spacing = 5 + } + + private let titleLabel = UILabel().then { + $0.textColor = CommonUIAssets.LMGray1 + $0.font = UIFont.systemFont(ofSize: 17, weight: .semibold) + } + + private let dateLabel = UILabel().then { + $0.textColor = CommonUIAssets.LMGray4 + $0.font = UIFont.systemFont(ofSize: 13, weight: .medium) + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + contentView.frame = contentView.frame.inset(by: UIEdgeInsets(top: 15, left: 0, bottom: 15, right: 0)) + backgroundColor = CommonUIAssets.LMWhite + layer.cornerRadius = 12 + layer.borderWidth = 1 + layer.borderColor = CommonUIAssets.LMGray4?.cgColor + selectionStyle = .none + + [profileIconView, labelStackView].forEach { addSubview($0) } + [titleLabel, dateLabel].forEach { labelStackView.addArrangedSubview($0) } + + profileIconView.snp.makeConstraints { + $0.leading.equalToSuperview().offset(15) + $0.centerY.equalToSuperview() + $0.width.height.equalTo(25) + } + + labelStackView.snp.makeConstraints { + $0.leading.equalTo(profileIconView.snp.trailing).offset(12) + $0.centerY.equalToSuperview() + } + } + + func configure(title: String, date: String) { + titleLabel.text = title + dateLabel.text = date + } +} diff --git a/Projects/CommonUI/Sources/View/Chat/ChatMainView.swift b/Projects/CommonUI/Sources/View/Chat/ChatMainView.swift new file mode 100644 index 0000000..d82c1dd --- /dev/null +++ b/Projects/CommonUI/Sources/View/Chat/ChatMainView.swift @@ -0,0 +1,191 @@ +// +// ChatMainView.swift +// CommonUI +// +// Created by 박지윤 on 7/2/25. +// + +import UIKit +import SnapKit +import Then +import Domain +import RxRelay +import RxSwift + +public class ChatMainView: UIView { + + public let newChatButtonTapped = PublishRelay() + public let chatCellTapped = PublishRelay() + let disposeBag = DisposeBag() + + private let emptyStateContainer = UIView() + + private let emptyIconImageView = UIImageView().then { + $0.image = CommonUIAssets.IconMessage + $0.contentMode = .scaleAspectFit + } + + private let emptyMessageLabel = UILabel().then { + $0.text = "저장된 대화가 없어요\n새로운 대화를 시작해보세요" + $0.textColor = CommonUIAssets.LMGray3 + $0.font = UIFont.systemFont(ofSize: 16, weight: .regular) + $0.textAlignment = .center + $0.numberOfLines = 2 + } + + private let chatListContainer = UIView() + + private let chatListTableView = UITableView().then { + $0.separatorStyle = .none + $0.backgroundColor = .clear + $0.showsVerticalScrollIndicator = false + } + + private var newChatButton = LMButton(textColor: CommonUIAssets.LMBlack, + bgColor: CommonUIAssets.LMOrange1) + + private var isShowingEmptyState = true + private var chatRoomList: [ChatRoomVO] = [] + + public override init(frame: CGRect) { + super.init(frame: frame) + initAttribute() + setupUI() + setupTableView() + bindEvents() + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func initAttribute() { + newChatButton = newChatButton.then { + $0.setTitle("새 대화 시작하기", for: .normal) + $0.setImage(CommonUIAssets.IconEdit? + .resize(to: CGSize(width: 20, height: 20)), for: .normal) + $0.semanticContentAttribute = .forceLeftToRight + $0.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 6) + } + } + + private func setupUI() { + backgroundColor = .clear + + [emptyStateContainer, chatListContainer, newChatButton].forEach { addSubview($0) } + + [emptyIconImageView, emptyMessageLabel].forEach { emptyStateContainer.addSubview($0) } + [chatListTableView].forEach { chatListContainer.addSubview($0) } + + setupConstraints() + showEmptyState() + } + + private func setupConstraints() { + emptyStateContainer.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.centerY.equalToSuperview().offset(-50) + $0.leading.trailing.equalToSuperview().inset(40) + } + + emptyIconImageView.snp.makeConstraints { + $0.top.equalToSuperview().offset(-20) + $0.centerX.equalToSuperview() + $0.width.height.equalTo(60) + } + + emptyMessageLabel.snp.makeConstraints { + $0.top.equalTo(emptyIconImageView.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + } + + chatListContainer.snp.makeConstraints { + $0.top.equalToSuperview().offset(20) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(newChatButton.snp.top).offset(-20) + } + + chatListTableView.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(20) + } + + newChatButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(20) + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-20) + } + } + + private func bindEvents() { + newChatButton.rx.tap + .bind(to: newChatButtonTapped) + .disposed(by: disposeBag) + } + + private func setupTableView() { + chatListTableView.delegate = self + chatListTableView.dataSource = self + chatListTableView.register(ChatListCell.self, forCellReuseIdentifier: "ChatListCell") + } + + public func showEmptyState() { + isShowingEmptyState = true + emptyStateContainer.isHidden = false + chatListContainer.isHidden = true + } + + public func showChatList() { + isShowingEmptyState = false + emptyStateContainer.isHidden = true + chatListContainer.isHidden = false + chatListTableView.reloadData() + } + + public func updateChatList(_ chatRoomListVO: ChatRoomListVO) { + self.chatRoomList = chatRoomListVO.chatRoomList + + if chatRoomListVO.chatRoomList.isEmpty { + showEmptyState() + } else { + showChatList() + } + } +} + +extension ChatMainView: UITableViewDataSource, UITableViewDelegate { + public func numberOfSections(in tableView: UITableView) -> Int { + return chatRoomList.count + } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 1 + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "ChatListCell", for: indexPath) as! ChatListCell + + let chatRoom = chatRoomList[indexPath.section] + cell.configure(title: chatRoom.title, date: chatRoom.createdAt) + + return cell + } + + public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 80 + } + + public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return section == 0 ? 0 : 5 + } + + public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return UIView() + } + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let chatRoom = chatRoomList[indexPath.section] + chatCellTapped.accept(chatRoom) + } +} diff --git a/Projects/CommonUI/Sources/View/Chat/ChatView.swift b/Projects/CommonUI/Sources/View/Chat/ChatView.swift index 3ab2d84..abdceec 100644 --- a/Projects/CommonUI/Sources/View/Chat/ChatView.swift +++ b/Projects/CommonUI/Sources/View/Chat/ChatView.swift @@ -13,6 +13,11 @@ import Then import RxRelay open class ChatView: UIView { + + public var onSendButtonTapped: ((String) -> Void)? + public var onEndButtonTapped: (() -> Void)? + let disposeBag = DisposeBag() + let titleLabel = UILabel().then { $0.text = "AI와 텍스트로 대화하세요" $0.textColor = .black @@ -82,6 +87,7 @@ open class ChatView: UIView { for (index, text) in newValue.enumerated() { if index < recommendLabels.count { recommendLabels[index].text = text + updateRecommendViewHeight(at: index) } } } @@ -103,11 +109,7 @@ open class ChatView: UIView { $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() @@ -123,6 +125,12 @@ open class ChatView: UIView { self?.chatTextField.text = "" }) .disposed(by: disposeBag) + + endButton.rx.tap + .subscribe(onNext: { [weak self] in + self?.onEndButtonTapped?() + }) + .disposed(by: disposeBag) } func initAttribute() { @@ -136,11 +144,11 @@ open class ChatView: UIView { chatScrollView.addSubview(chatStackView) // 추천 뷰들을 StackView에 추가하고 각각에 라벨 추가 - for (view, label) in zip(recommendViews, recommendLabels) { + for (index, (view, label)) in zip(recommendViews, recommendLabels).enumerated() { recommendStackView.addArrangedSubview(view) view.addSubview(label) - // 뷰 높이 설정 + // 초기 뷰 높이 설정 (최소 높이) view.snp.makeConstraints { $0.height.equalTo(40) } // 라벨 레이아웃 설정 @@ -199,7 +207,7 @@ open class ChatView: UIView { } sendButton.snp.makeConstraints { - $0.bottom.equalTo(self.safeAreaLayoutGuide).offset(-30) + $0.bottom.equalTo(self.safeAreaLayoutGuide).offset(-20) $0.height.width.equalTo(44) $0.trailing.equalToSuperview().inset(20) } @@ -227,6 +235,21 @@ open class ChatView: UIView { } } + // endButton 숨기기 메서드 + public func hideEndButton() { + endButton.isHidden = true + } + + // endButton 보이기 메서드 + public func showEndButton() { + endButton.isHidden = false + } + + // subtitleLabel 텍스트 변경 메서드 + public func updateSubtitleText(_ text: String) { + subtitleLabel.text = text + } + // 메시지 추가 메서드 public func addMessageToUI(_ message: ChatMessageVO) { let messageView = createMessageView(message) @@ -285,6 +308,31 @@ open class ChatView: UIView { return containerView } + // 추천 뷰 높이를 동적으로 업데이트하는 메서드 + private func updateRecommendViewHeight(at index: Int) { + guard index < recommendLabels.count && index < recommendViews.count else { return } + + let label = recommendLabels[index] + let view = recommendViews[index] + + // 라벨의 intrinsic content size를 계산 + let maxWidth: CGFloat = 280 - 32 // recommendStackView width - label insets (16 * 2) + let size = label.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.infinity)) + + // 최소 높이 40, 최대 높이 100으로 제한 + let calculatedHeight = max(40, min(100, size.height + 16)) // 16은 상하 패딩 + + // 기존 높이 constraint 업데이트 + view.snp.updateConstraints { make in + make.height.equalTo(calculatedHeight) + } + + // 애니메이션과 함께 레이아웃 업데이트 + UIView.animate(withDuration: 0.3) { + self.layoutIfNeeded() + } + } + required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/Projects/CommonUI/Sources/View/Navigation/DefaultNavigationBar.swift b/Projects/CommonUI/Sources/View/Navigation/DefaultNavigationBar.swift index e3bc20f..0b0ca19 100644 --- a/Projects/CommonUI/Sources/View/Navigation/DefaultNavigationBar.swift +++ b/Projects/CommonUI/Sources/View/Navigation/DefaultNavigationBar.swift @@ -16,8 +16,7 @@ public final class DefaultNavigationBar: UIView { public init(leftImage: UIImage?, rightImage: UIImage?, - title: String?, - isRightButtonHidden: Bool) { + title: String?) { super.init(frame: .zero) setupUI() setupLayout() @@ -25,9 +24,9 @@ public final class DefaultNavigationBar: UIView { leftButton.setImage(leftImage, for: .normal) rightButton.setImage(rightImage, for: .normal) titleLabel.text = title - rightButton.isHidden = isRightButtonHidden leftButton.addTarget(self, action: #selector(leftButtonTapped), for: .touchUpInside) + rightButton.addTarget(self, action: #selector(rightButtonTapped), for: .touchUpInside) } required init?(coder: NSCoder) { @@ -75,12 +74,18 @@ public final class DefaultNavigationBar: UIView { titleLabel.text = title } - @objc private func leftButtonTapped() { + @objc public func leftButtonTapped() { if let viewController = findViewController() { viewController.navigationController?.popViewController(animated: true) } } - + + @objc public func rightButtonTapped() { + if let viewController = findViewController() { + viewController.navigationController?.popToRootViewController(animated: true) + } + } + private func findViewController() -> UIViewController? { var responder: UIResponder? = self while let nextResponder = responder?.next { diff --git a/Projects/Data/Data.xcodeproj/project.pbxproj b/Projects/Data/Data.xcodeproj/project.pbxproj index e90f791..048ab16 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 */; }; + 9516ED5D2E71D3FE00F548A1 /* ChatRoomDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9516ED5C2E71D3FB00F548A1 /* ChatRoomDTO.swift */; }; + 9516ED612E71D5B800F548A1 /* ChatDetailDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9516ED602E71D5B200F548A1 /* ChatDetailDTO.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 */; }; @@ -47,6 +49,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 = ""; }; + 9516ED5C2E71D3FB00F548A1 /* ChatRoomDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRoomDTO.swift; sourceTree = ""; }; + 9516ED602E71D5B200F548A1 /* ChatDetailDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDetailDTO.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; }; @@ -81,7 +85,7 @@ 647255CD65221C9CD4A43DED /* DTO */ = { isa = PBXGroup; children = ( - 951F3F8E2E6F36440022583B /* ChatDTO.swift */, + 9516ED642E75609400F548A1 /* Chat */, 950A0D8F2E6039D300C07CF2 /* DefaultDTO.swift */, AAAC2885ACFE998578DC25E8 /* CourseDTO.swift */, A44BC1E2E75FC256F832CA38 /* LoginDTO.swift */, @@ -111,6 +115,16 @@ ); sourceTree = ""; }; + 9516ED642E75609400F548A1 /* Chat */ = { + isa = PBXGroup; + children = ( + 951F3F8E2E6F36440022583B /* ChatDTO.swift */, + 9516ED5C2E71D3FB00F548A1 /* ChatRoomDTO.swift */, + 9516ED602E71D5B200F548A1 /* ChatDetailDTO.swift */, + ); + path = Chat; + sourceTree = ""; + }; A46DA33BCE1E2288153B2AC3 /* Network */ = { isa = PBXGroup; children = ( @@ -218,7 +232,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9516ED612E71D5B800F548A1 /* ChatDetailDTO.swift in Sources */, 43E9C2380F425520C1FA1AD2 /* CourseDTO.swift in Sources */, + 9516ED5D2E71D3FE00F548A1 /* ChatRoomDTO.swift in Sources */, FF43B3A4D0DC88307E918DB0 /* LoginDTO.swift in Sources */, E9463A3FF42D5F0960245F80 /* QuizDTO.swift in Sources */, 684AAEA9796EED3F9FC592FC /* NetworkConfiguration.swift in Sources */, diff --git a/Projects/Data/Sources/DTO/ChatDTO.swift b/Projects/Data/Sources/DTO/Chat/ChatDTO.swift similarity index 96% rename from Projects/Data/Sources/DTO/ChatDTO.swift rename to Projects/Data/Sources/DTO/Chat/ChatDTO.swift index bad3905..e46337c 100644 --- a/Projects/Data/Sources/DTO/ChatDTO.swift +++ b/Projects/Data/Sources/DTO/Chat/ChatDTO.swift @@ -41,16 +41,15 @@ public struct ChatMessageDataDTO: Decodable { 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 - ) + content: content) } } + +public struct ChatMessageRequestDTO: Encodable { + public let content: String +} diff --git a/Projects/Data/Sources/DTO/Chat/ChatDetailDTO.swift b/Projects/Data/Sources/DTO/Chat/ChatDetailDTO.swift new file mode 100644 index 0000000..70f8985 --- /dev/null +++ b/Projects/Data/Sources/DTO/Chat/ChatDetailDTO.swift @@ -0,0 +1,46 @@ +// +// ChatDetailDTO.swift +// Data +// +// Created by 박지윤 on 9/11/25. +// + +import Domain + +public struct ChatDetailResponseDTO: Decodable { + public let is_success: Bool + public let code: String + public let message: String + public let data: ChatDetailDataDTO +} + +public struct ChatDetailDataDTO: Decodable { + public let chatRoom: ChatRoomDataDTO + public let chatList: [ChatListDTO] +} + +public struct ChatListDTO: Decodable { + public let chatId: Int + public let author: Int + public let content: String + public let comment: String? + public let createdAt: String +} + +extension ChatDetailDataDTO { + func toDomain() -> ChatDetailVO { + let chatRoomVO = ChatRoomVO(chatRoomId: chatRoom.chatRoomId, + title: chatRoom.title, + createdAt: chatRoom.createdAt) + + let chatListVO = chatList.map { chat in + ChatListVO(chatId: chat.chatId, + author: chat.author, + content: chat.content, + comment: chat.comment, + createdAt: chat.createdAt) + } + + return ChatDetailVO(chatRoom: chatRoomVO, chatList: chatListVO) + } +} diff --git a/Projects/Data/Sources/DTO/Chat/ChatRoomDTO.swift b/Projects/Data/Sources/DTO/Chat/ChatRoomDTO.swift new file mode 100644 index 0000000..0843d78 --- /dev/null +++ b/Projects/Data/Sources/DTO/Chat/ChatRoomDTO.swift @@ -0,0 +1,39 @@ +// +// ChatRoomDTO.swift +// Data +// +// Created by 박지윤 on 9/11/25. +// + +import Domain + +public struct ChatRoomResponseDTO: Decodable { + public let is_success: Bool + public let code: String + public let message: String + public let data: ChatRoomListDTO +} + +public struct ChatRoomListDTO: Decodable { + public let chatRoomList: [ChatRoomDataDTO] +} + +public struct ChatRoomDataDTO: Decodable { + public let chatRoomId: Int + public let title: String + public let createdAt: String +} + +extension ChatRoomDataDTO { + func toDomain() -> ChatRoomVO { + return ChatRoomVO(chatRoomId: chatRoomId, + title: title, + createdAt: createdAt) + } +} + +extension ChatRoomListDTO { + func toDomain() -> ChatRoomListVO { + return ChatRoomListVO(chatRoomList: chatRoomList.map { $0.toDomain() }) + } +} diff --git a/Projects/Data/Sources/Repository/ChatRepository.swift b/Projects/Data/Sources/Repository/ChatRepository.swift index 38ac1a8..e616bcd 100644 --- a/Projects/Data/Sources/Repository/ChatRepository.swift +++ b/Projects/Data/Sources/Repository/ChatRepository.swift @@ -16,69 +16,111 @@ public class DefaultChatRepository: ChatRepository { self.tokenRepository = tokenRepository } + /// 텍스트 대화 시작하기 public func postChatStart() -> Single { + return request(method: .post, + endpoint: "/api/chats/text", + responseType: ChatResponseDTO.self + ) + .map { dto in + return dto.data.toDomain() + } + } + + /// 텍스트 대화하기 + public func postChat(chatRoomId: Int, content: String) -> Single { + let parameter = ["content": content] + + return request(method: .post, + parameters: parameter, + endpoint: "/api/chats/text/\(chatRoomId)", + responseType: ChatMessageResponseDTO.self + ) + .map { dto in + return dto.data.toDomain() + } + } + + /// 대화방 삭제하기 + public func deleteChat(chatRoomId: Int) -> Single { + return request(method: .delete, + endpoint: "/api/chats/\(chatRoomId)", + responseType: DefaultDTO.self + ) + .map { dto in + return dto.getMessage() + } + } + + /// 대화 분석하기 + public func postChatAnalysis(chatRoomId: Int) -> Single { + return request(method: .post, + endpoint: "/api/chats/\(chatRoomId)/analysis", + responseType: ChatDetailResponseDTO.self + ) + .map { dto in + return dto.data.toDomain() + } + } + + /// 저장된 대화 내역 리스트 조회하기 + public func getChatList() -> Single { + return request(endpoint: "/api/chats", + responseType: ChatRoomResponseDTO.self + ) + .map { dto in + return dto.data.toDomain() + } + } + + /// 저장된 대화 내역 상세 조회하기 + public func getChatDetail(chatRoomId: Int) -> Single { + return request(endpoint: "/api/chats/\(chatRoomId)", + responseType: ChatDetailResponseDTO.self + ) + .map { dto in + return dto.data.toDomain() + } + } + + private func request( + method: HTTPMethod = .get, + parameters: [String: Any]? = nil, + endpoint: String, + encoding: ParameterEncoding = JSONEncoding.default, + responseType: T.Type + ) -> Single { return Single.create { single in - let url = "\(NetworkConfiguration.baseUrl)/api/chats/text" + let url = "\(NetworkConfiguration.baseUrl)\(endpoint)" var headers: HTTPHeaders = [:] if let token = self.tokenRepository.getAccessToken() { + print("🔑 사용할 토큰: \(token)") headers.add(name: "Authorization", value: "Bearer \(token)") + } else { + print("❌ 토큰이 없습니다!") } + print("🌐 API 요청 URL: \(url)") + print("🔑 Authorization 헤더: \(headers)") - print("[텍스트 대화 시작 POST] URL: \(url)") - print("[텍스트 대화 시작 POST] 헤더: \(headers)") - let request = AF.request(url, - method: .post, - encoding: JSONEncoding.default, + method: method, + parameters: parameters, + encoding: encoding, headers: headers) .validate() - .responseDecodable(of: ChatResponseDTO.self) { response in + .responseDecodable(of: responseType) { response in switch response.result { case .success(let value): - print("[텍스트 대화 시작 POST] 성공: \(value)") - single(.success(value.data.toDomain())) + print("✅ API 응답 성공: \(value)") + single(.success(value)) case .failure(let error): - print("[텍스트 대화 시작 POST] 실패: \(error)") + print("❌ API 응답 실패: \(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/Domain/Domain.xcodeproj/project.pbxproj b/Projects/Domain/Domain.xcodeproj/project.pbxproj index 30ba42e..52beb99 100644 --- a/Projects/Domain/Domain.xcodeproj/project.pbxproj +++ b/Projects/Domain/Domain.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 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 */; }; + 9516ED592E71CE1300F548A1 /* ChatDetailVO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9516ED582E71CE1000F548A1 /* ChatDetailVO.swift */; }; + 9516ED5B2E71CF0300F548A1 /* ChatRoomListVO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9516ED5A2E71CEFE00F548A1 /* ChatRoomListVO.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 */; }; @@ -58,6 +60,8 @@ 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 = ""; }; + 9516ED582E71CE1000F548A1 /* ChatDetailVO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDetailVO.swift; sourceTree = ""; }; + 9516ED5A2E71CEFE00F548A1 /* ChatRoomListVO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRoomListVO.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 = ""; }; @@ -163,6 +167,8 @@ isa = PBXGroup; children = ( 951F3F8C2E6F36090022583B /* ChatVO.swift */, + 9516ED5A2E71CEFE00F548A1 /* ChatRoomListVO.swift */, + 9516ED582E71CE1000F548A1 /* ChatDetailVO.swift */, 950A0D912E603D9C00C07CF2 /* DefaultVO.swift */, 747DBBCAB797E5E0A82177F2 /* CourseVO.swift */, FD08A7186FB9676854B7AEAC /* LoginVO.swift */, @@ -260,7 +266,9 @@ 950A0D6B2E5CCBF000C07CF2 /* SignRepository.swift in Sources */, 69DAD609572D32F2BA3845AE /* String+Extension.swift in Sources */, 950A0D822E5DE10C00C07CF2 /* LoginUseCase.swift in Sources */, + 9516ED592E71CE1300F548A1 /* ChatDetailVO.swift in Sources */, 92BD46EE48F6C63B6E43D069 /* UIView+Extension.swift in Sources */, + 9516ED5B2E71CF0300F548A1 /* ChatRoomListVO.swift in Sources */, D510BC17C4583615CB60439E /* CourseRepository.swift in Sources */, D7012CC494E56CD8CE58176F /* LoginRepository.swift in Sources */, 1782D2A1A7FB2BD6FFA1DFEA /* QuizRepository.swift in Sources */, diff --git a/Projects/Domain/Sources/RepositoryProtocol/ChatRepository.swift b/Projects/Domain/Sources/RepositoryProtocol/ChatRepository.swift index d67b153..31524ac 100644 --- a/Projects/Domain/Sources/RepositoryProtocol/ChatRepository.swift +++ b/Projects/Domain/Sources/RepositoryProtocol/ChatRepository.swift @@ -10,4 +10,8 @@ import RxSwift public protocol ChatRepository { func postChatStart() -> Single func postChat(chatRoomId: Int, content: String) -> Single + func deleteChat(chatRoomId: Int) -> Single + func postChatAnalysis(chatRoomId: Int) -> Single + func getChatList() -> Single + func getChatDetail(chatRoomId: Int) -> Single } diff --git a/Projects/Domain/Sources/UseCase/ChatUseCase.swift b/Projects/Domain/Sources/UseCase/ChatUseCase.swift index ceb1f46..5cd0ca2 100644 --- a/Projects/Domain/Sources/UseCase/ChatUseCase.swift +++ b/Projects/Domain/Sources/UseCase/ChatUseCase.swift @@ -10,6 +10,10 @@ import RxSwift public protocol ChatUseCase { func postChatStart() -> Single func postChat(chatRoomId: Int, content: String) -> Single + func deleteChat(chatRoomId: Int) -> Single + func postChatAnalysis(chatRoomId: Int) -> Single + func getChatList() -> Single + func getChatDetail(chatRoomId: Int) -> Single } public final class DefaultChatUseCase: ChatUseCase { @@ -26,4 +30,20 @@ public final class DefaultChatUseCase: ChatUseCase { public func postChat(chatRoomId: Int, content: String) -> Single { return repository.postChat(chatRoomId: chatRoomId, content: content) } + + public func deleteChat(chatRoomId: Int) -> Single { + return repository.deleteChat(chatRoomId: chatRoomId) + } + + public func postChatAnalysis(chatRoomId: Int) -> Single { + return repository.postChatAnalysis(chatRoomId: chatRoomId) + } + + public func getChatList() -> Single { + return repository.getChatList() + } + + public func getChatDetail(chatRoomId: Int) -> Single { + return repository.getChatDetail(chatRoomId: chatRoomId) + } } diff --git a/Projects/Domain/Sources/VO/ChatDetailVO.swift b/Projects/Domain/Sources/VO/ChatDetailVO.swift new file mode 100644 index 0000000..dffa0e4 --- /dev/null +++ b/Projects/Domain/Sources/VO/ChatDetailVO.swift @@ -0,0 +1,36 @@ +// +// ChatDetailVO.swift +// Domain +// +// Created by 박지윤 on 9/11/25. +// + +public struct ChatDetailVO { + public let chatRoom: ChatRoomVO + public let chatList: [ChatListVO] + + public init(chatRoom: ChatRoomVO, chatList: [ChatListVO]) { + self.chatRoom = chatRoom + self.chatList = chatList + } +} + +public struct ChatListVO { + public let chatId: Int + public let author: Int + public let content: String + public let comment: String? + public let createdAt: String + + public init(chatId: Int, + author: Int, + content: String, + comment: String?, + createdAt: String) { + self.chatId = chatId + self.author = author + self.content = content + self.comment = comment + self.createdAt = createdAt + } +} diff --git a/Projects/Domain/Sources/VO/ChatRoomListVO.swift b/Projects/Domain/Sources/VO/ChatRoomListVO.swift new file mode 100644 index 0000000..06d205c --- /dev/null +++ b/Projects/Domain/Sources/VO/ChatRoomListVO.swift @@ -0,0 +1,26 @@ +// +// ChatRoomListVO.swift +// Domain +// +// Created by 박지윤 on 9/11/25. +// + +public struct ChatRoomListVO { + public let chatRoomList: [ChatRoomVO] + + public init(chatRoomList: [ChatRoomVO]) { + self.chatRoomList = chatRoomList + } +} + +public struct ChatRoomVO { + public let chatRoomId: Int + public let title: String + public let createdAt: String + + public init(chatRoomId: Int, title: String, createdAt: String) { + self.chatRoomId = chatRoomId + self.title = title + self.createdAt = createdAt + } +} diff --git a/Projects/Home/Sources/View/QuizViewController.swift b/Projects/Home/Sources/View/QuizViewController.swift index 9b483d7..f1f6f3d 100644 --- a/Projects/Home/Sources/View/QuizViewController.swift +++ b/Projects/Home/Sources/View/QuizViewController.swift @@ -14,8 +14,7 @@ import RxSwift public class QuizViewController: UIViewController { let navigationBar = DefaultNavigationBar(leftImage: CommonUIAssets.IconBack ?? nil, rightImage: nil, - title: nil, - isRightButtonHidden: true) + title: nil) let progressView = UIView().then { $0.backgroundColor = CommonUIAssets.LMOrange1 $0.layer.cornerRadius = 3 diff --git a/Projects/LearnMate/Sources/Coordinator/TabBarCoordinator.swift b/Projects/LearnMate/Sources/Coordinator/TabBarCoordinator.swift index 97d0b2f..7c7d334 100644 --- a/Projects/LearnMate/Sources/Coordinator/TabBarCoordinator.swift +++ b/Projects/LearnMate/Sources/Coordinator/TabBarCoordinator.swift @@ -77,10 +77,10 @@ final class DefaultTabBarController: TabBarCoordinator { return UITabBarItem(title: page.tabIconName(), image: CommonUIAssets.tabIconDiary?.original, selectedImage: CommonUIAssets.tabIconDiarySelected?.original) - case .stats: - return UITabBarItem(title: page.tabIconName(), - image: CommonUIAssets.tabIconStats?.original, - selectedImage: CommonUIAssets.tabIconStatsSelected?.original) +// case .stats: +// return UITabBarItem(title: page.tabIconName(), +// image: CommonUIAssets.tabIconStats?.original, +// selectedImage: CommonUIAssets.tabIconStatsSelected?.original) case .myPage: return UITabBarItem(title: page.tabIconName(), image: CommonUIAssets.tabIconMypage?.original, @@ -95,8 +95,8 @@ final class DefaultTabBarController: TabBarCoordinator { 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) + let chatMainViewController = dependency.injector.resolve(ChatMainViewController.self) + tabNavigationController.pushViewController(chatMainViewController, animated: true) default: let viewController = UIViewController() viewController.view.backgroundColor = .black @@ -106,15 +106,15 @@ final class DefaultTabBarController: TabBarCoordinator { } enum TabBarPage: String, CaseIterable { - case home, chat, diary, stats, myPage + case home, chat, diary, myPage init?(index: Int) { switch index { case 0: self = .home case 1: self = .chat case 2: self = .diary - case 3: self = .stats - case 4: self = .myPage +// case 3: self = .stats + case 3: self = .myPage default: return nil } } @@ -124,8 +124,8 @@ enum TabBarPage: String, CaseIterable { case .home: return 0 case .chat: return 1 case .diary: return 2 - case .stats: return 3 - case .myPage: return 4 +// case .stats: return 3 + case .myPage: return 3 } } @@ -134,7 +134,7 @@ enum TabBarPage: String, CaseIterable { case .home: return "홈" case .chat: return "대화" case .diary: return "일기" - case .stats: return "통계" +// case .stats: return "통계" case .myPage: return "마이페이지" } } diff --git a/Projects/LearnMate/Sources/DI/ChatAssembly.swift b/Projects/LearnMate/Sources/DI/ChatAssembly.swift index 6459851..b547bb7 100644 --- a/Projects/LearnMate/Sources/DI/ChatAssembly.swift +++ b/Projects/LearnMate/Sources/DI/ChatAssembly.swift @@ -18,9 +18,9 @@ public struct ChatAssembly: Assembly { tokenUseCase: tokenUseCase) } - container.register(ChatViewController.self) { resolver in + container.register(ChatMainViewController.self) { resolver in let chatViewModel = resolver.resolve(ChatViewModel.self)! - return ChatViewController(chatViewModel: chatViewModel) + return ChatMainViewController(chatViewModel: chatViewModel) } } } diff --git a/Projects/Login/Sources/View/SignInViewController.swift b/Projects/Login/Sources/View/SignInViewController.swift index 871bf97..7ac1a2c 100644 --- a/Projects/Login/Sources/View/SignInViewController.swift +++ b/Projects/Login/Sources/View/SignInViewController.swift @@ -15,8 +15,7 @@ public class SignInViewController: BaseViewController { let navigationBar = DefaultNavigationBar(leftImage: CommonUIAssets.IconBack ?? nil, rightImage: nil, - title: nil, - isRightButtonHidden: true) + title: nil) let signInView = SignInView() public var onPresentSignUp: (() -> Void)? diff --git a/Projects/Login/Sources/View/SignUpViewController.swift b/Projects/Login/Sources/View/SignUpViewController.swift index 7d1abfe..125234a 100644 --- a/Projects/Login/Sources/View/SignUpViewController.swift +++ b/Projects/Login/Sources/View/SignUpViewController.swift @@ -16,8 +16,7 @@ public class SignUpViewController: BaseViewController { let navigationBar = DefaultNavigationBar(leftImage: CommonUIAssets.IconBack ?? nil, rightImage: nil, - title: "회원가입", - isRightButtonHidden: true) + title: "회원가입") let scrollView = UIScrollView() let signUpView = SignUpView()