feat(chat-room) 채팅방
- 텍스트 메시지 UI 적용
This commit is contained in:
@@ -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[..<closeIndex])
|
||||
let afterClose = String(component[component.index(after: closeIndex)...])
|
||||
|
||||
// 소괄호 안의 텍스트 (특별 스타일)
|
||||
result = result + Text("(\(beforeClose))")
|
||||
.font(.system(size: 16, design: .default).italic())
|
||||
.foregroundColor(Color(hex: "e2e2e2").opacity(0.49))
|
||||
|
||||
// 소괄호 뒤의 텍스트 (일반 스타일)
|
||||
if !afterClose.isEmpty {
|
||||
result = result + Text(afterClose)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
} else {
|
||||
// 닫는 괄호가 없으면 일반 텍스트로 처리
|
||||
result = result + Text("(\(component)")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AiMessageItemView()
|
||||
AiMessageItemView(
|
||||
message: ServerChatMessage(
|
||||
messageId: 1,
|
||||
message: "(언제부턴가) 너랑 노는게 제일 재밌고\n너랑 이야기 하는게 제일 신나더라,\n앞으로도 그럴 것 같아❤️",
|
||||
profileImageUrl: "https://example.com/profile.jpg",
|
||||
mine: false,
|
||||
createdAt: Date().currentTimeMillis(),
|
||||
messageType: "text",
|
||||
imageUrl: nil,
|
||||
price: nil,
|
||||
hasAccess: true
|
||||
),
|
||||
characterName: "보라"
|
||||
)
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,152 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct UserMessageBubbleShape: Shape {
|
||||
func path(in rect: CGRect) -> 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[..<closeIndex])
|
||||
let afterClose = String(component[component.index(after: closeIndex)...])
|
||||
|
||||
// 소괄호 안의 텍스트 (특별 스타일)
|
||||
result = result + Text("(\(beforeClose))")
|
||||
.font(.system(size: 16, design: .default).italic())
|
||||
.foregroundColor(Color(hex: "333333"))
|
||||
|
||||
// 소괄호 뒤의 텍스트 (일반 스타일)
|
||||
if !afterClose.isEmpty {
|
||||
result = result + Text(afterClose)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
} else {
|
||||
// 닫는 괄호가 없으면 일반 텍스트로 처리
|
||||
result = result + Text("(\(component)")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 16))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
UserMessageItemView()
|
||||
UserMessageItemView(
|
||||
message: ServerChatMessage(
|
||||
messageId: 1,
|
||||
message: "(만약에) 멈춰 인프피",
|
||||
profileImageUrl: "",
|
||||
mine: true,
|
||||
createdAt: Date().currentTimeMillis(),
|
||||
messageType: "text",
|
||||
imageUrl: nil,
|
||||
price: nil,
|
||||
hasAccess: true
|
||||
)
|
||||
)
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user