feat(chat-room): 채팅방에서 메시지 보내기 API 연동
- 타이핑 indicator 동작하지 않던 버그 수정 - 이미지 4:5 비율로 보이도록 수정
This commit is contained in:
		@@ -139,6 +139,14 @@ struct ChatRoomView: View {
 | 
			
		||||
                                        }
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                                
 | 
			
		||||
                                if viewModel.showSendingMessage {
 | 
			
		||||
                                    TypingIndicatorItemView(
 | 
			
		||||
                                        characterName: viewModel.characterName,
 | 
			
		||||
                                        characterProfileUrl: viewModel.characterProfileUrl
 | 
			
		||||
                                    )
 | 
			
		||||
                                    .id(viewModel.messages.count)
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                            .padding(.horizontal, 24)
 | 
			
		||||
                            .frame(minHeight: geometry.size.height, alignment: .bottom)
 | 
			
		||||
@@ -146,7 +154,12 @@ struct ChatRoomView: View {
 | 
			
		||||
                        .onChange(of: viewModel.messages.count) { _ in
 | 
			
		||||
                            if !viewModel.messages.isEmpty {
 | 
			
		||||
                                withAnimation(.easeOut(duration: 0.3)) {
 | 
			
		||||
                                    proxy.scrollTo(viewModel.messages.count - 1, anchor: .bottom)
 | 
			
		||||
                                    proxy.scrollTo(
 | 
			
		||||
                                        viewModel.showSendingMessage ?
 | 
			
		||||
                                        viewModel.messages.count :
 | 
			
		||||
                                        viewModel.messages.count - 1,
 | 
			
		||||
                                        anchor: .bottom
 | 
			
		||||
                                    )
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,8 @@ final class ChatRoomViewModel: ObservableObject {
 | 
			
		||||
    @Published private(set) var countdownText: String = "00:00:00"
 | 
			
		||||
    @Published private(set) var showQuotaNoticeView: Bool = false
 | 
			
		||||
    
 | 
			
		||||
    @Published private(set) var showSendingMessage: Bool = false
 | 
			
		||||
    
 | 
			
		||||
    // MARK: - Message State
 | 
			
		||||
    @Published var messageText: String = ""
 | 
			
		||||
    @Published private(set) var messages: [ServerChatMessage] = []
 | 
			
		||||
@@ -48,8 +50,59 @@ final class ChatRoomViewModel: ObservableObject {
 | 
			
		||||
        let message = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
 | 
			
		||||
        messageText = ""
 | 
			
		||||
        
 | 
			
		||||
        // TODO: 실제 메시지 전송 로직 구현
 | 
			
		||||
        DEBUG_LOG("메시지 전송: \(message)")
 | 
			
		||||
        let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
 | 
			
		||||
        
 | 
			
		||||
        messages.append(
 | 
			
		||||
            ServerChatMessage(
 | 
			
		||||
                messageId: 0 - nowMs,
 | 
			
		||||
                message: message,
 | 
			
		||||
                profileImageUrl: "",
 | 
			
		||||
                mine: true,
 | 
			
		||||
                createdAt: nowMs,
 | 
			
		||||
                messageType: "TEXT",
 | 
			
		||||
                imageUrl: nil,
 | 
			
		||||
                price: nil,
 | 
			
		||||
                hasAccess: true
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
        
 | 
			
		||||
        showSendingMessage = true
 | 
			
		||||
        repository.sendMessage(roomId: roomId, message: message)
 | 
			
		||||
            .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<SendChatMessageResponse>.self, from: responseData)
 | 
			
		||||
                    
 | 
			
		||||
                    if let data = decoded.data, decoded.success {
 | 
			
		||||
                        self?.messages.append(contentsOf: data.messages)
 | 
			
		||||
                        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?.showSendingMessage = false
 | 
			
		||||
                } catch {
 | 
			
		||||
                    self?.showSendingMessage = false
 | 
			
		||||
                    self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
 | 
			
		||||
                    self?.isShowPopup = true
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            .store(in: &subscription)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    @MainActor
 | 
			
		||||
 
 | 
			
		||||
@@ -93,16 +93,13 @@ struct AiMessageItemView: View {
 | 
			
		||||
                        let maxWidth = (UIScreen.main.bounds.width - 48) * 0.7
 | 
			
		||||
                        let imageHeight = maxWidth * 5 / 4 // 4:5 비율
 | 
			
		||||
                        
 | 
			
		||||
                        KFImage(URL(string: imageUrl))
 | 
			
		||||
                            .placeholder {
 | 
			
		||||
                                Rectangle()
 | 
			
		||||
                                    .fill(Color.gray.opacity(0.3))
 | 
			
		||||
                                    .frame(width: maxWidth, height: imageHeight)
 | 
			
		||||
                            }
 | 
			
		||||
                            .resizable()
 | 
			
		||||
                            .aspectRatio(4/5, contentMode: .fit)
 | 
			
		||||
                            .frame(width: maxWidth, height: imageHeight)
 | 
			
		||||
                            .cornerRadius(10)
 | 
			
		||||
                        ZStack {
 | 
			
		||||
                            KFImage(URL(string: imageUrl))
 | 
			
		||||
                                .resizable()
 | 
			
		||||
                                .scaledToFill() // 비율 유지하며 프레임을 채움
 | 
			
		||||
                        }
 | 
			
		||||
                        .frame(width: maxWidth, height: imageHeight)
 | 
			
		||||
                        .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
 | 
			
		||||
                    } else {
 | 
			
		||||
                        // 텍스트 메시지 버블
 | 
			
		||||
                        HStack(spacing: 10) {
 | 
			
		||||
 
 | 
			
		||||
@@ -10,10 +10,13 @@ import Kingfisher
 | 
			
		||||
 | 
			
		||||
struct TypingIndicatorItemView: View {
 | 
			
		||||
    var dotCount: Int = 3
 | 
			
		||||
    var size: CGFloat = 6
 | 
			
		||||
    var size: CGFloat = 8
 | 
			
		||||
    var spacing: CGFloat = 6
 | 
			
		||||
    var color: Color = .secondary
 | 
			
		||||
    var period: Double = 1.2   // 초
 | 
			
		||||
    var color: Color = .primary
 | 
			
		||||
    /// 한 주기(모든 점이 한 번 튀는 데 걸리는 시간, 초)
 | 
			
		||||
    var period: Double = 1.2
 | 
			
		||||
    /// 각 점의 위상 차이(라디안)
 | 
			
		||||
    var phaseStep: Double = 0.7
 | 
			
		||||
    
 | 
			
		||||
    let characterName: String
 | 
			
		||||
    let characterProfileUrl: String
 | 
			
		||||
@@ -40,17 +43,22 @@ struct TypingIndicatorItemView: View {
 | 
			
		||||
                HStack(spacing: 10) {
 | 
			
		||||
                    TimelineView(.animation) { context in
 | 
			
		||||
                        let t = context.date.timeIntervalSinceReferenceDate
 | 
			
		||||
                        let base = (t.truncatingRemainder(dividingBy: period)) / period
 | 
			
		||||
                        HStack(spacing: spacing) {
 | 
			
		||||
                            ForEach(0..<dotCount, id: \.self) { i in
 | 
			
		||||
                                let angle = base * 2 * .pi - Double(i) * phaseStep
 | 
			
		||||
                                // 0...1 로 정규화된 파형
 | 
			
		||||
                                let wave = (sin(angle) + 1) / 2
 | 
			
		||||
                                Circle()
 | 
			
		||||
                                    .fill(color)
 | 
			
		||||
                                    .frame(width: size, height: size)
 | 
			
		||||
                                    .opacity(opacity(for: i, time: t))
 | 
			
		||||
                                    .scaleEffect(0.7 + 0.3 * wave)      // 0.7 ~ 1.0
 | 
			
		||||
                                    .opacity(0.35 + 0.65 * wave)        // 0.35 ~ 1.0
 | 
			
		||||
                                    .accessibilityHidden(true)
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        // 레이아웃이 미세하게 흔들리지 않도록 애니메이션은 투명도에만 적용
 | 
			
		||||
                        .animation(.easeInOut(duration: period / Double(dotCount)).repeatForever(autoreverses: true), value: context.date)
 | 
			
		||||
                    }
 | 
			
		||||
                    .accessibilityLabel(Text("입력 중"))
 | 
			
		||||
                }
 | 
			
		||||
                .padding(.horizontal, 10)
 | 
			
		||||
                .padding(.vertical, 8)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user