feat(chat-room) 채팅방 API

- 채팅방 입장 API 연동
- 채팅 쿼터가 없을 때 표시할 UI 추가
This commit is contained in:
Yu Sung
2025-09-04 04:20:45 +09:00
parent 96cabbc6a7
commit 2576c851ee
9 changed files with 353 additions and 47 deletions

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

View File

@@ -35,4 +35,9 @@ class ChatRoomRepository {
func sendMessage(roomId: Int, message: String) -> AnyPublisher<Response, MoyaError> {
return talkApi.requestPublisher(.sendMessage(roomId: roomId, request: SendChatMessageRequest(message: message)))
}
/** */
func getChatQuotaStatus() -> AnyPublisher<Response, MoyaError> {
return talkApi.requestPublisher(.getChatQuotaStatus)
}
}

View File

@@ -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,6 +155,7 @@ struct ChatRoomView: View {
.frame(width: screenSize().width)
.frame(maxHeight: .infinity)
if !viewModel.showQuotaNoticeView {
HStack(spacing: 8) {
HStack(spacing: 0) {
ZStack(alignment: .leading) {
@@ -181,23 +198,26 @@ struct ChatRoomView: View {
}
}
}
.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()
}

View File

@@ -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<AnyCancellable>()
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<ChatRoomEnterResponse>.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<ChatQuotaStatusResponse>.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)"
}
}

View File

@@ -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
}
}
}

View File

@@ -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") {}
}

View File

@@ -7,5 +7,5 @@
struct ChatQuotaStatusResponse: Decodable {
let totalRemaining: Int
let nextRechargeAtEpoch: Int64
let nextRechargeAtEpoch: Int64?
}

View File

@@ -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
}
}