diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift index 4874900..012e701 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift @@ -6,14 +6,192 @@ // import SwiftUI +import Kingfisher +import UIKit struct ChatRoomView: View { @StateObject var viewModel = ChatRoomViewModel() + @AppStorage("can") private var can: Int = UserDefaults.int(forKey: .can) + var body: some View { BaseView(isLoading: $viewModel.isLoading) { + ChatRoomBgView() + VStack(spacing: 0) { + HStack(spacing: 12) { + Image("ic_back") + .resizable() + .frame(width: 24, height: 24) + .onTapGesture { + AppState.shared.back() + } + + KFImage(URL(string: viewModel.characterProfileUrl)) + .placeholder { + Image(systemName: "person.crop.circle") + .resizable() + .scaledToFit() + } + .resizable() + .frame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.characterName) + .font(.custom(Font.preBold.rawValue, size: 12)) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(viewModel.characterType.rawValue) + .font(.custom(Font.preBold.rawValue, size: 10)) + .foregroundColor(.white) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background( + Color(hex: + viewModel.characterType == .Clone ? + "0020C9" : "009D68" + ) + ) + .cornerRadius(6) + } + + Spacer() + + HStack(spacing: 4) { + Image("ic_can") + .resizable() + .frame(width: 20, height: 20) + + Text("\(can)") + .font(.custom(Font.preRegular.rawValue, size: 16)) + .foregroundColor(.white) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color(hex: "263238")) + .cornerRadius(30) + + Image("ic_seemore_vertical_white") + .resizable() + .frame(width: 24, height: 24) + .onTapGesture {} + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .frame(width: screenSize().width, height: 60) + + HStack(spacing: 8) { + Image(systemName: "info.circle.fill") + .resizable() + .frame(width: 20, height: 20) + + Text( + viewModel.characterType == .Character + ? "보이스온 AI캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다.\n세계관 속 캐릭터로 대화를 하거나 새로운 인물로 캐릭터와 당신만의 스토리를 만들어보세요.\n※ AI캐릭터톡은 오픈베타 서비스 중이며, 캐릭터의 대화가 어색하거나 불완전할 수 있습니다." + : "AI Clone은 크리에이터의 정보를 기반으로 대화하지만, 모든 정보를 완벽하게 반영하거나 실제 대화와 일치하지 않을 수 있습니다." + ) + .font(.custom(Font.preRegular.rawValue, size: 12)) + .foregroundColor(.white) + + Image(systemName: "chevron.up") + .resizable() + .scaledToFit() + .frame(width: 20) + } + .padding(12) + .background(Color(hex: "13181B").opacity(0.7)) + .cornerRadius(16) + .frame(width: screenSize().width - 48) + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 16) { + Spacer() + + ForEach(0..() + + // MARK: - Actions + @MainActor + func sendMessage() { + guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + let message = messageText.trimmingCharacters(in: .whitespacesAndNewlines) + messageText = "" + + // TODO: 실제 메시지 전송 로직 구현 + DEBUG_LOG("메시지 전송: \(message)") + } } diff --git a/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift b/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift index a6931d2..6ebcd86 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Message/AiMessageItemView.swift @@ -6,13 +6,176 @@ // import SwiftUI +import Kingfisher + +struct AiMessageBubbleShape: Shape { + func path(in rect: CGRect) -> Path { + let path = UIBezierPath() + + // 시작점 (왼쪽 상단, 4px 반지름) + path.move(to: CGPoint(x: 4, y: 0)) + + // 상단 라인 (오른쪽 상단 16px 반지름까지) + path.addLine(to: CGPoint(x: rect.width - 16, y: 0)) + + // 오른쪽 상단 모서리 (16px 반지름) + path.addArc(withCenter: CGPoint(x: rect.width - 16, y: 16), + radius: 16, + startAngle: -CGFloat.pi / 2, + endAngle: 0, + clockwise: true) + + // 오른쪽 라인 (오른쪽 하단 16px 반지름까지) + path.addLine(to: CGPoint(x: rect.width, y: rect.height - 16)) + + // 오른쪽 하단 모서리 (16px 반지름) + path.addArc(withCenter: CGPoint(x: rect.width - 16, y: rect.height - 16), + radius: 16, + startAngle: 0, + endAngle: CGFloat.pi / 2, + clockwise: true) + + // 하단 라인 (왼쪽 하단 16px 반지름까지) + path.addLine(to: CGPoint(x: 16, y: rect.height)) + + // 왼쪽 하단 모서리 (16px 반지름) + path.addArc(withCenter: CGPoint(x: 16, y: rect.height - 16), + radius: 16, + startAngle: CGFloat.pi / 2, + endAngle: CGFloat.pi, + clockwise: true) + + // 왼쪽 라인 (왼쪽 상단 4px 반지름까지) + path.addLine(to: CGPoint(x: 0, y: 4)) + + // 왼쪽 상단 모서리 (4px 반지름) + path.addArc(withCenter: CGPoint(x: 4, y: 4), + radius: 4, + startAngle: CGFloat.pi, + endAngle: -CGFloat.pi / 2, + clockwise: true) + + path.close() + return Path(path.cgPath) + } +} struct AiMessageItemView: View { + let message: ServerChatMessage + let characterName: String + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + HStack(alignment: .bottom, spacing: 4) { + // 메시지 영역 + HStack(alignment: .top, spacing: 9) { + // 프로필 이미지 + KFImage(URL(string: message.profileImageUrl)) + .placeholder { + Image(systemName: "person.crop.circle") + .resizable() + .scaledToFit() + } + .resizable() + .frame(width: 30, height: 30) + .clipShape(Circle()) + + // 메시지 텍스트 영역 + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Text(characterName) + .font(.custom(Font.preRegular.rawValue, size: 12)) + .foregroundColor(.white) + } + + // 메시지 버블 + HStack(spacing: 10) { + styledMessageText(message.message) + .lineLimit(nil) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + Color.black.opacity(0.1) + ) + .clipShape(AiMessageBubbleShape()) + } + } + + // 시간 표시 + VStack { + Text(formatTime(from: message.createdAt)) + .font(.custom(Font.preRegular.rawValue, size: 10)) + .foregroundColor(.white) + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func formatTime(from timestamp: Int64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)) + return date.convertDateFormat(dateFormat: "a h:mm") + } + + private func styledMessageText(_ message: String) -> Text { + var result = Text("") + let components = message.components(separatedBy: "(") + + for (index, component) in components.enumerated() { + if index == 0 { + // 첫 번째 컴포넌트는 항상 일반 텍스트 + if !component.isEmpty { + result = result + Text(component) + .font(.custom(Font.preRegular.rawValue, size: 16)) + .foregroundColor(.white) + } + } else { + // "(" 이후의 텍스트 처리 + if let closeIndex = component.firstIndex(of: ")") { + let beforeClose = String(component[.. Path { + let path = UIBezierPath() + + // 시작점 (왼쪽 상단, 16px 반지름) + path.move(to: CGPoint(x: 16, y: 0)) + + // 상단 라인 (오른쪽 상단 4px 반지름까지) + path.addLine(to: CGPoint(x: rect.width - 4, y: 0)) + + // 오른쪽 상단 모서리 (4px 반지름) + path.addArc(withCenter: CGPoint(x: rect.width - 4, y: 4), + radius: 4, + startAngle: -CGFloat.pi / 2, + endAngle: 0, + clockwise: true) + + // 오른쪽 라인 (오른쪽 하단 16px 반지름까지) + path.addLine(to: CGPoint(x: rect.width, y: rect.height - 16)) + + // 오른쪽 하단 모서리 (16px 반지름) + path.addArc(withCenter: CGPoint(x: rect.width - 16, y: rect.height - 16), + radius: 16, + startAngle: 0, + endAngle: CGFloat.pi / 2, + clockwise: true) + + // 하단 라인 (왼쪽 하단 16px 반지름까지) + path.addLine(to: CGPoint(x: 16, y: rect.height)) + + // 왼쪽 하단 모서리 (16px 반지름) + path.addArc(withCenter: CGPoint(x: 16, y: rect.height - 16), + radius: 16, + startAngle: CGFloat.pi / 2, + endAngle: CGFloat.pi, + clockwise: true) + + // 왼쪽 라인 (왼쪽 상단 16px 반지름까지) + path.addLine(to: CGPoint(x: 0, y: 16)) + + // 왼쪽 상단 모서리 (16px 반지름) + path.addArc(withCenter: CGPoint(x: 16, y: 16), + radius: 16, + startAngle: CGFloat.pi, + endAngle: -CGFloat.pi / 2, + clockwise: true) + + path.close() + return Path(path.cgPath) + } +} + struct UserMessageItemView: View { + let message: ServerChatMessage + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + HStack(alignment: .bottom, spacing: 4) { + Spacer() + + // 시간 표시 + VStack { + Text(formatTime(from: message.createdAt)) + .font(.custom(Font.preRegular.rawValue, size: 10)) + .foregroundColor(.white) + } + + // 메시지 버블 + HStack(spacing: 9) { + VStack(alignment: .trailing, spacing: 4) { + // 메시지 텍스트 + HStack(spacing: 10) { + styledMessageText(message.message) + .lineLimit(nil) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.button) + .clipShape(UserMessageBubbleShape()) + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private func formatTime(from timestamp: Int64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)) + return date.convertDateFormat(dateFormat: "a h:mm") + } + + private func styledMessageText(_ message: String) -> Text { + var result = Text("") + let components = message.components(separatedBy: "(") + + for (index, component) in components.enumerated() { + if index == 0 { + // 첫 번째 컴포넌트는 항상 일반 텍스트 + if !component.isEmpty { + result = result + Text(component) + .font(.custom(Font.preRegular.rawValue, size: 16)) + .foregroundColor(.white) + } + } else { + // "(" 이후의 텍스트 처리 + if let closeIndex = component.firstIndex(of: ")") { + let beforeClose = String(component[..