From 2576c851ee006fddc48fcaa5562204ff88602137 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 4 Sep 2025 04:20:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-room)=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채팅방 입장 API 연동 - 채팅 쿼터가 없을 때 표시할 UI 추가 --- .../ic_time.imageset/Contents.json | 21 ++ .../ic_time.imageset/ic_time.png | Bin 0 -> 578 bytes .../Chat/Talk/Room/ChatRoomRepository.swift | 5 + .../Sources/Chat/Talk/Room/ChatRoomView.swift | 106 ++++++---- .../Chat/Talk/Room/ChatRoomViewModel.swift | 194 ++++++++++++++++++ .../Talk/Room/Message/ServerChatMessage.swift | 10 +- .../Room/Quota/ChatQuotaNoticeItemView.swift | 51 ++++- .../Room/Quota/ChatQuotaStatusResponse.swift | 2 +- SodaLive/Sources/Chat/Talk/TalkApi.swift | 11 + 9 files changed, 353 insertions(+), 47 deletions(-) create mode 100644 SodaLive/Resources/Assets.xcassets/ic_time.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_time.imageset/ic_time.png diff --git a/SodaLive/Resources/Assets.xcassets/ic_time.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_time.imageset/Contents.json new file mode 100644 index 0000000..73870e3 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_time.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "ic_time.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_time.imageset/ic_time.png b/SodaLive/Resources/Assets.xcassets/ic_time.imageset/ic_time.png new file mode 100644 index 0000000000000000000000000000000000000000..150da3cd2e6def03cc9abfafbbf16ab396ef2173 GIT binary patch literal 578 zcmV-I0=@l-P)t~zgbkWb02`1_&=HyqV1tkepc9Zz;L5dpC@J_sonQU#bWT9h zC&>>Pfd7UhL|IC?#34mlq1>}qjglluI*17*%up)nhSJ&k_)HvxDGu)_f*zegXLq9C z3aSc^petd}p}b-R4iE|?%~>Wb$_tg+W|NVwK@K56gOVBVAP{I%F#FQiC?|uSN<9l` zyCxs*%oP!fGzE8n>nQ%%{1l!Jor{N+y`56XtP11A6?lLp^lY9j*n6}83W$$48ZQ^B z+9qr+mM$+dGG4NU_T%4VY!*q4oBj-uK|UM$`!q&;g4^Mf#s^Mp${RgfhK_SBU>;0> zYu8?$!s05Lm5&k5nVPQ}bo585yVAyCCTJY0%2w$SJV7gUFE5+I!J=RNI>AD>)0IOZ zhpx1@=#2Yk5fu;_ayS*Mv6rUYeQ4Gm#4Y*WPAQnI{d%Y$X-+;{n-XthUUw%4>6ZBu zF6A9l4!(U%kQTm1{OXd2%sK6lGMIFNyrm3;gD{sv7qV8&b-fihs4XDaV|8kEFdX`K zpqmUoI&09^+Xnsu3}A!uA?;9^R?_o=81xB4L8PE7prl9n0;<9-dUYV AnyPublisher { return talkApi.requestPublisher(.sendMessage(roomId: roomId, request: SendChatMessageRequest(message: message))) } + + /** 쿼터 상태 조회 */ + func getChatQuotaStatus() -> AnyPublisher { + return talkApi.requestPublisher(.getChatQuotaStatus) + } } diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift index 443d2c7..6802bee 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift @@ -19,7 +19,7 @@ struct ChatRoomView: View { var body: some View { BaseView(isLoading: $viewModel.isLoading) { - ChatRoomBgView() + ChatRoomBgView(url: viewModel.chatRoomBgImageUrl) VStack(spacing: 0) { HStack(spacing: 12) { @@ -38,6 +38,7 @@ struct ChatRoomView: View { } .resizable() .frame(width: 36, height: 36) + .clipShape(Circle()) VStack(alignment: .leading, spacing: 4) { Text(viewModel.characterName) @@ -116,11 +117,26 @@ struct ChatRoomView: View { let message = viewModel.messages[index] if message.mine { UserMessageItemView(message: message) + .id(index) } else { AiMessageItemView( message: message, characterName: viewModel.characterName ) + .id(index) + } + } + + if viewModel.showQuotaNoticeView { + ChatQuotaNoticeItemView(remainingTime: viewModel.countdownText) { + + } + .id(viewModel.messages.count) + .padding(.bottom, 12) + .onAppear { + withAnimation(.easeOut(duration: 0.3)) { + proxy.scrollTo(viewModel.messages.count, anchor: .bottom) + } } } } @@ -139,65 +155,69 @@ struct ChatRoomView: View { .frame(width: screenSize().width) .frame(maxHeight: .infinity) - HStack(spacing: 8) { - HStack(spacing: 0) { - ZStack(alignment: .leading) { - if viewModel.messageText.isEmpty { - Text("메시지를 입력하세요.") - .font(.custom(Font.preRegular.rawValue, size: 14)) - .foregroundColor(Color(hex: "78909C")) - } - - TextField("", text: $viewModel.messageText) - .font(.custom(Font.preRegular.rawValue, size: 14)) - .foregroundColor(.white) - .onSubmit { - viewModel.sendMessage() + if !viewModel.showQuotaNoticeView { + HStack(spacing: 8) { + HStack(spacing: 0) { + ZStack(alignment: .leading) { + if viewModel.messageText.isEmpty { + Text("메시지를 입력하세요.") + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(Color(hex: "78909C")) } + + TextField("", text: $viewModel.messageText) + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(.white) + .onSubmit { + viewModel.sendMessage() + } + } + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 16) + .padding(.vertical, 13) + .background(Color(hex: "263238")) + .cornerRadius(999) + .overlay( + RoundedRectangle(cornerRadius: 999) + .stroke(Color(hex: "263238"), lineWidth: 1) + ) + + Button(action: { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + viewModel.sendMessage() + }) { + Image("ic_message_send") + .resizable() + .frame(width: 24, height: 24) } - .frame(maxWidth: .infinity) - } - .padding(.horizontal, 16) - .padding(.vertical, 13) - .background(Color(hex: "263238")) - .cornerRadius(999) - .overlay( - RoundedRectangle(cornerRadius: 999) - .stroke(Color(hex: "263238"), lineWidth: 1) - ) - - Button(action: { - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - viewModel.sendMessage() - }) { - Image("ic_message_send") - .resizable() - .frame(width: 24, height: 24) } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .frame(width: screenSize().width) } - .padding(.horizontal, 12) - .padding(.vertical, 12) - .frame(width: screenSize().width) } } + .onAppear { + viewModel.enterRoom(roomId: roomId) + } + .onDisappear { + viewModel.stopTimer() + } } } struct ChatRoomBgView: View { - let url: String? = nil + let url: String? var body: some View { ZStack { if let url = url { KFImage(URL(string: url)) .resizable() - .scaledToFill() - .ignoresSafeArea() - } else { - Image("img_sample") - .resizable() - .scaledToFill() + .aspectRatio(4/5, contentMode: .fill) + .frame(maxWidth: screenSize().width) .ignoresSafeArea() } diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift index c4fc60e..0fa3b1a 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift @@ -18,6 +18,11 @@ final class ChatRoomViewModel: ObservableObject { @Published private(set) var characterProfileUrl: String = "" @Published private(set) var characterName: String = "Character Name" @Published private(set) var characterType: CharacterType = .Character + @Published private(set) var chatRoomBgImageUrl: String? = nil + @Published private(set) var roomId: Int = 0 + + @Published private(set) var countdownText: String = "00:00:00" + @Published private(set) var showQuotaNoticeView: Bool = false // MARK: - Message State @Published var messageText: String = "" @@ -28,6 +33,11 @@ final class ChatRoomViewModel: ObservableObject { private let repository = ChatRoomRepository() private var subscription = Set() + private var hasMoreMessages: Bool = true + private var nextCursor: Int64? = nil + + private var timer: Timer? + // MARK: - Actions @MainActor func sendMessage() { @@ -41,4 +51,188 @@ final class ChatRoomViewModel: ObservableObject { // TODO: 실제 메시지 전송 로직 구현 DEBUG_LOG("메시지 전송: \(message)") } + + @MainActor + func enterRoom(roomId: Int) { + isLoading = true + self.roomId = roomId + + repository.enterChatRoom( + roomId: roomId, + characterImageId: getSavedBackgroundImageId() + ) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [weak self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self?.characterName = data.character.name + self?.characterType = data.character.characterType + self?.characterProfileUrl = data.character.profileImageUrl + + self?.chatRoomBgImageUrl = data.bgImageUrl ?? data.character.profileImageUrl + self?.messages.insert(contentsOf: data.messages.sorted(), at: 0) + + self?.hasMoreMessages = data.hasMoreMessages + self?.nextCursor = data.messages.last?.messageId + + self?.updateQuota(totalRemaining: data.totalRemaining, nextRechargeAtEpoch: data.nextRechargeAtEpoch) + } else { + if let message = decoded.message { + self?.errorMessage = message + } else { + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self?.isShowPopup = true + } + + self?.isLoading = false + } catch { + self?.isLoading = false + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.isShowPopup = true + } + } + .store(in: &subscription) + } + + func purchaseChatQuota() { + + } + + private func checkQuotaStatus() { + isLoading = true + + repository.getChatQuotaStatus() + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [weak self] response in + let responseData = response.data + + DEBUG_LOG(String(data: responseData, encoding: .utf8) ?? "") + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self?.updateQuota(totalRemaining: data.totalRemaining, nextRechargeAtEpoch: data.nextRechargeAtEpoch) + } else { + if let message = decoded.message { + self?.errorMessage = message + } else { + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self?.isShowPopup = true + } + + self?.isLoading = false + } catch { + self?.isLoading = false + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.isShowPopup = true + } + } + .store(in: &subscription) + } + + private func updateQuota(totalRemaining: Int, nextRechargeAtEpoch: Int64?) { + isLoading = true + stopTimer() + + // epoch 없음 → 카운트다운 비표시 + guard let nextRechargeAtEpoch else { + countdownText = "00:00:00" + showQuotaNoticeView = false + isLoading = false + return + } + + // 즉시 1회 갱신 + let remainMs = remainingMs(to: nextRechargeAtEpoch) + updateCountdownText(remainMs) + + // 이미 0이면 종료 처리 + guard remainMs > 0 else { + checkQuotaStatus() + return + } + + isLoading = false + showQuotaNoticeView = true + + // 타이머 시작 (1초마다 갱신) + startTimer(targetEpoch: nextRechargeAtEpoch) + } + + private func updateCountdownText(_ remainMs: Int64) { + countdownText = remainMs > 0 ? formatMillisToHms(remainMs) : "00:00:00" + } + + private func startTimer(targetEpoch: Int64) { + stopTimer() + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self else { return } + let remain = self.remainingMs(to: targetEpoch) + self.updateCountdownText(remain) + if remain == 0 { + self.stopTimer() + self.checkQuotaStatus() + } + } + if let t = timer { RunLoop.main.add(t, forMode: .common) } + } + + func stopTimer() { + timer?.invalidate() + timer = nil + } + + private func remainingMs(to epoch: Int64) -> Int64 { + let ms = normalizeToMs(epoch) + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + let fudgeMs: Int64 = 5000 + + // Kotlin 로직과 동일하게 표시 보정 적용 + return max(ms - nowMs + fudgeMs, 0) + } + + /// 초 단위/밀리초 단위 혼용 대비 + private func normalizeToMs(_ epoch: Int64) -> Int64 { + epoch < 1_000_000_000_000 ? epoch * 1000 : epoch + } + + private func formatMillisToHms(_ ms: Int64) -> String { + let total = ms / 1000 + let h = total / 3600 + let m = (total % 3600) / 60 + let s = total % 60 + return String(format: "%02d:%02d:%02d", h, m, s) + } + + private func getSavedBackgroundImageId() -> Int? { + let imageId = UserDefaults.standard.integer(forKey: bgImageIdKey()) + return imageId > 0 ? imageId : nil + } + + private func bgImageIdKey() -> String { + return "chat_bg_image_id_room_\(roomId)" + } } diff --git a/SodaLive/Sources/Chat/Talk/Room/Message/ServerChatMessage.swift b/SodaLive/Sources/Chat/Talk/Room/Message/ServerChatMessage.swift index 3329eb1..fdf2125 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Message/ServerChatMessage.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Message/ServerChatMessage.swift @@ -5,7 +5,7 @@ // Created by klaus on 9/2/25. // -struct ServerChatMessage: Decodable { +struct ServerChatMessage: Decodable, Comparable { let messageId: Int64 let message: String let profileImageUrl: String @@ -15,4 +15,12 @@ struct ServerChatMessage: Decodable { let imageUrl: String? let price: Int? let hasAccess: Bool + + static func < (lhs: ServerChatMessage, rhs: ServerChatMessage) -> Bool { + if lhs.createdAt == rhs.createdAt { + return lhs.messageId < rhs.messageId + } else { + return lhs.createdAt < rhs.createdAt + } + } } diff --git a/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift b/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift index 9745453..e5decfb 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift @@ -8,11 +8,58 @@ import SwiftUI struct ChatQuotaNoticeItemView: View { + + let remainingTime: String + let purchase: () -> Void + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + VStack(spacing: 10) { + VStack(spacing: 8) { + Image("ic_time") + .resizable() + .frame(width: 30, height: 30) + + Text(remainingTime) + .font(.custom(Font.preBold.rawValue, size: 18)) + .foregroundColor(.white) + + Text("기다리면 무료 이용이 가능합니다.") + .font(.custom(Font.preBold.rawValue, size: 18)) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 15) + .background(Color(hex: "EC8280")) + .cornerRadius(10) + + HStack(spacing: 4) { + Image("ic_can") + + Text("30") + .font(.custom(Font.preBold.rawValue, size: 24)) + .foregroundColor(Color(hex: "263238")) + + Text("결제하고 바로 대화 시작") + .font(.custom(Font.preBold.rawValue, size: 24)) + .foregroundColor(Color(hex: "263238")) + .padding(.leading, 4) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color(hex: "B5E7FA")) + .cornerRadius(30) + .overlay { + RoundedRectangle(cornerRadius: 30) + .stroke(lineWidth: 1) + .foregroundColor(Color.button) + } + .onTapGesture { + purchase() + } + } } } #Preview { - ChatQuotaNoticeItemView() + ChatQuotaNoticeItemView(remainingTime: "05:59:55") {} } diff --git a/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaStatusResponse.swift b/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaStatusResponse.swift index c7cefe3..8e3a83c 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaStatusResponse.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaStatusResponse.swift @@ -7,5 +7,5 @@ struct ChatQuotaStatusResponse: Decodable { let totalRemaining: Int - let nextRechargeAtEpoch: Int64 + let nextRechargeAtEpoch: Int64? } diff --git a/SodaLive/Sources/Chat/Talk/TalkApi.swift b/SodaLive/Sources/Chat/Talk/TalkApi.swift index dcc8303..93bc41b 100644 --- a/SodaLive/Sources/Chat/Talk/TalkApi.swift +++ b/SodaLive/Sources/Chat/Talk/TalkApi.swift @@ -14,6 +14,8 @@ enum TalkApi { case enterChatRoom(roomId: Int, characterImageId: Int?) case sendMessage(roomId: Int, request: SendChatMessageRequest) case getChatRoomMessages(roomId: Int, cursor: Int?, limit: Int) + + case getChatQuotaStatus } extension TalkApi: TargetType { @@ -35,6 +37,9 @@ extension TalkApi: TargetType { case .getChatRoomMessages(let roomId, _, _): return "/api/chat/room/\(roomId)/messages" + + case .getChatQuotaStatus: + return "/api/chat/quota/me" } } @@ -54,6 +59,9 @@ extension TalkApi: TargetType { case .getChatRoomMessages: return .get + + case .getChatQuotaStatus: + return .get } } @@ -91,6 +99,9 @@ extension TalkApi: TargetType { parameters: parameters, encoding: URLEncoding.queryString ) + + case .getChatQuotaStatus: + return .requestPlain } }