// // CharacterDetailView.swift // SodaLive // // Created by klaus on 8/29/25. // import SwiftUI import Kingfisher struct CharacterDetailView: View { let characterId: Int @StateObject var viewModel = CharacterDetailViewModel() @State private var selectedTab: InnerTab = .detail @State private var showMoreWorldView = false @State private var showMorePersonality = false @Environment(\.presentationMode) var presentationMode: Binding private enum InnerTab: Int, CaseIterable { case detail = 0 case gallery = 1 var title: String { switch self { case .detail: return "상세" case .gallery: return "갤러리" } } } var body: some View { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { DetailNavigationBar(title: "캐릭터 정보") { if presentationMode.wrappedValue.isPresented { presentationMode.wrappedValue.dismiss() } else { AppState.shared.back() } } tabBar ZStack(alignment: .bottom) { if selectedTab == .detail { // 상세 탭 ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { // 캐릭터 이미지 섹션 characterImageSection .id("top") // 스크롤 최상단 식별자 // 프로필 정보 섹션 profileSection // 세계관 및 작품 소개 if let backgrounds = viewModel.characterDetail?.backgrounds { worldViewSection(backgrounds: backgrounds) } // 원작 섹션 if let originalTitle = viewModel.characterDetail?.originalTitle, let originalLink = viewModel.characterDetail?.originalLink { originalWorkSection(title: originalTitle, link: originalLink) } // 성격 및 특징 섹션 if let personalities = viewModel.characterDetail?.personalities { personalitySection(personalities: personalities) } // 장르의 다른 캐릭터 섹션 if let others = viewModel.characterDetail?.others, !others.isEmpty { VStack(spacing: 16) { HStack { Text("장르의 다른 캐릭터") .font(.custom(Font.preBold.rawValue, size: 26)) .foregroundColor(.white) Spacer() } .padding(.horizontal, 24) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { ForEach(others, id: \.characterId) { otherCharacter in CharacterItemView( character: Character( characterId: otherCharacter.characterId, name: otherCharacter.name, description: otherCharacter.tags, imageUrl: otherCharacter.imageUrl ), size: screenSize().width * 0.42, rank: 0, isShowRank: false ) .onTapGesture { // 캐릭터 변경 후 스크롤을 최상단으로 이동 viewModel.characterId = otherCharacter.characterId // 스크롤을 최상단으로 이동 proxy.scrollTo("top") } } } .padding(.leading, 24) } } .padding(.vertical, 16) } Spacer(minLength: 64) } } } } else { // 갤러리 탭 CharacterDetailGalleryView(characterId: characterId) } // 대화하기 버튼 if selectedTab == .detail { chatButton } } } .navigationTitle("") .navigationBarBackButtonHidden() .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { GeometryReader { geo in HStack { Spacer() Text(viewModel.errorMessage) .padding(.vertical, 13.3) .frame(alignment: .center) .frame(maxWidth: .infinity) .padding(.horizontal, 33.3) .font(.custom(Font.medium.rawValue, size: 12)) .background(Color.button) .foregroundColor(Color.white) .multilineTextAlignment(.center) .cornerRadius(20) .padding(.top, 66.7) Spacer() } } } } .onAppear { viewModel.characterId = characterId } } } // MARK: - Tab Bar extension CharacterDetailView { private var tabBar: some View { HStack(spacing: 0) { ChatInnerTab( title: InnerTab.detail.title, isSelected: selectedTab == .detail, onTap: { if selectedTab != .detail { selectedTab = .detail } } ) ChatInnerTab( title: InnerTab.gallery.title, isSelected: selectedTab == .gallery, onTap: { if selectedTab != .gallery { selectedTab = .gallery } } ) } } } // MARK: - Character Image Section extension CharacterDetailView { private var characterImageSection: some View { ZStack { if let imageUrl = viewModel.characterDetail?.imageUrl{ // 배경 이미지 KFImage(URL(string: imageUrl)) .cancelOnDisappear(true) .resizable() .scaledToFill() .frame(width: screenSize().width, height: screenSize().width, alignment: .top) .clipped() } } } } // MARK: - Profile Section extension CharacterDetailView { private var profileSection: some View { VStack(alignment: .leading, spacing: 16) { if viewModel.characterDetail?.mbti != nil || viewModel.characterDetail?.gender != nil || viewModel.characterDetail?.age != nil { HStack(spacing: 4) { if let gender = viewModel.characterDetail?.gender { Text(gender) .font(.custom(Font.preRegular.rawValue, size: 14)) .foregroundColor( gender == "남성" ? Color.button : Color.mainRed ) .padding(.horizontal, 7) .padding(.vertical, 3) .background(Color(hex: "263238")) .cornerRadius(4) .overlay { RoundedRectangle(cornerRadius: 4) .stroke(lineWidth: 1) .foregroundColor( gender == "남성" ? Color.button : Color.mainRed ) } } if let age = viewModel.characterDetail?.age { Text("\(age)세") .font(.custom(Font.preRegular.rawValue, size: 14)) .foregroundColor(Color(hex: "B0BEC5")) .padding(.horizontal, 7) .padding(.vertical, 3) .background(Color(hex: "263238")) .cornerRadius(4) .overlay { RoundedRectangle(cornerRadius: 4) .stroke(lineWidth: 1) .foregroundColor(.white.opacity(0.5)) } } if let mbti = viewModel.characterDetail?.mbti { Text(mbti) .font(.custom(Font.preRegular.rawValue, size: 14)) .foregroundColor(Color(hex: "B0BEC5")) .padding(.horizontal, 7) .padding(.vertical, 3) .background(Color(hex: "263238")) .cornerRadius(4) .overlay { RoundedRectangle(cornerRadius: 4) .stroke(lineWidth: 1) .foregroundColor(.white.opacity(0.5)) } } } } // 이름과 상태 HStack(spacing: 8) { Text(viewModel.characterDetail?.name ?? "") .font(.custom(Font.preBold.rawValue, size: 26)) .foregroundColor(.white) .lineLimit(1) .truncationMode(.tail) if let characterType = viewModel.characterDetail?.characterType { HStack(spacing: 8) { Text(characterType.rawValue) .font(.custom(Font.preRegular.rawValue, size: 12)) .foregroundColor(.white) .padding(.horizontal, 5) .padding(.vertical, 1) .background(characterType == .Clone ? Color(hex: "0020C9") : Color(hex: "009D68")) .cornerRadius(6) } } } // 설명 Text(viewModel.characterDetail?.description ?? "") .font(.custom(Font.preRegular.rawValue, size: 18)) .foregroundColor(Color(hex: "B0BEC5")) Text(viewModel.characterDetail?.tags ?? "") .font(.custom(Font.preRegular.rawValue, size: 14)) .foregroundColor(Color(hex: "3BB9F1")) .multilineTextAlignment(.leading) } .padding(.horizontal, 24) } } // MARK: - World View Section extension CharacterDetailView { private func worldViewSection(backgrounds: CharacterBackgroundResponse) -> some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("[세계관 및 작품 소개]") .font(.custom(Font.preBold.rawValue, size: 18)) .foregroundColor(.white) Spacer() } CharacterExpandableTextView(text: backgrounds.description) } .padding(.horizontal, 24) } } // MARK: - Original Work Section extension CharacterDetailView { private func originalWorkSection(title: String, link: String) -> some View { VStack(spacing: 8) { HStack { Text("원작") .font(.custom(Font.preBold.rawValue, size: 16)) .fontWeight(.bold) .foregroundColor(.white) Spacer() } HStack { Text(title) .font(.custom(Font.preRegular.rawValue, size: 16)) .foregroundColor(Color(hex: "B0BEC5")) Spacer() } Button(action: { if let url = URL(string: link) { UIApplication.shared.open(url) } }) { Text("원작 보러가기") .font(.custom(Font.preBold.rawValue, size: 16)) .fontWeight(.bold) .foregroundColor(Color(hex: "3BB9F1")) .frame(maxWidth: .infinity) .frame(height: 48) .background( RoundedRectangle(cornerRadius: 8) .stroke(Color(hex: "3BB9F1"), lineWidth: 1) ) } } .padding(.horizontal, 36) } } // MARK: - Personality Section extension CharacterDetailView { private func personalitySection(personalities: CharacterPersonalityResponse) -> some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("[성격 및 특징]") .font(.custom(Font.preBold.rawValue, size: 18)) .foregroundColor(.white) Spacer() } CharacterExpandableTextView(text: personalities.description) // 캐릭터톡 대화 가이드 VStack(alignment: .leading, spacing: 16) { HStack { Text("⚠️ 캐릭터톡 대화 가이드") .font(.custom(Font.preBold.rawValue, size: 16)) .foregroundColor(Color(hex: "B0BEC5")) Spacer() } Text(""" 보이스온의 오픈월드 캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다. 세계관 속 연관 캐릭터가 되어 대화를 하거나 완전히 새로운 인물이 되어 캐릭터와 당신만의 스토리를 만들어 갈 수 있습니다. """) .font(.custom(Font.preRegular.rawValue, size: 16)) .foregroundColor(Color(hex: "AEAEB2")) .multilineTextAlignment(.leading) Text(""" 오픈월드 캐릭터톡은 캐릭터를 정교하게 설계하였지만, 대화가 어색하거나 불완전할 수도 있습니다. 대화 도중 캐릭터의 대화가 이상하거나 새로운 캐릭터로 대화를 나누고 싶다면 대화를 초기화 하고 새롭게 캐릭터와 대화를 나눠보세요. """) .font(.custom(Font.preRegular.rawValue, size: 16)) .foregroundColor(Color(hex: "AEAEB2")) .multilineTextAlignment(.leading) } .padding(16) .background( RoundedRectangle(cornerRadius: 16) .stroke(Color(hex: "37474F"), lineWidth: 1) ) .cornerRadius(16) .padding(.top, 8) } .padding(.horizontal, 24) } } // MARK: - Chat Button extension CharacterDetailView { private var chatButton: some View { Text("대화하기") .font(.custom(Font.preBold.rawValue, size: 18)) .foregroundColor(.white) .frame(maxWidth: .infinity) .frame(height: 54) .background(Color.button) .cornerRadius(16) .padding(.horizontal, 24) .onTapGesture { viewModel.createChatRoom { AppState.shared .setAppStep( step: .chatRoom(id: $0) ) } } } } #Preview { CharacterDetailView(characterId: 1) } // MARK: - Character Expandable Text View struct CharacterExpandableTextView: View { @State private var isExpanded = false @State private var isTruncated = false let text: String var body: some View { VStack(alignment: .leading, spacing: 8) { Text(text) .font(.custom(Font.preRegular.rawValue, size: 16)) .foregroundColor(Color(hex: "B0BEC5")) .lineLimit(isExpanded ? nil : 3) .multilineTextAlignment(.leading) .background( GeometryReader { proxy in Color.clear .onAppear { let customFont = UIFont(name: Font.preRegular.rawValue, size: 16) ?? UIFont.systemFont(ofSize: 16) let lineHeight = customFont.lineHeight let maxHeight = lineHeight * 3 isTruncated = proxy.size.height > maxHeight && !isExpanded } } ) if isTruncated || isExpanded { HStack { Spacer() HStack(spacing: 4) { Image(systemName: "chevron.down") .font(.system(size: 16)) .foregroundColor(Color(hex: "607D8B")) .rotationEffect(.degrees(isExpanded ? 180 : 0)) Text(isExpanded ? "간략히" : "더보기") .font(.custom(Font.preRegular.rawValue, size: 16)) .foregroundColor(Color(hex: "607D8B")) } .onTapGesture { withAnimation(.easeInOut(duration: 0.3)) { isExpanded.toggle() } } Spacer() } } } } }