// // 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 I18n.Tab.detail case .gallery: return I18n.Tab.gallery } } } var body: some View { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { DetailNavigationBar(title: I18n.Chat.Character.detailTitle) { 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: viewModel.characterDetail?.translated?.background?.description ?? backgrounds.description) } // 원작 섹션 if let originalTitle = viewModel.characterDetail?.originalTitle, let originalLink = viewModel.characterDetail?.originalLink { originalWorkSection(title: originalTitle, link: originalLink) } // 성격 및 특징 섹션 if let personalities = viewModel.characterDetail?.personalities { personalitySection(personalities: viewModel.characterDetail?.translated?.personality?.description ?? personalities.description) } // 장르의 다른 캐릭터 섹션 if let others = viewModel.characterDetail?.others, !others.isEmpty { VStack(spacing: 16) { HStack { Text(I18n.Chat.Character.detailOtherCharactersTitle) .appFont(size: 26, weight: .bold) .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, isNew: false ), 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() .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) } .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 func isMaleGender(_ gender: String) -> Bool { let normalizedGender = gender .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() return normalizedGender == "남성" || normalizedGender == "male" } 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(viewModel.characterDetail?.translated?.gender ?? gender) .appFont(size: 14, weight: .regular) .foregroundColor( isMaleGender(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( isMaleGender(gender) ? Color.button : Color.mainRed ) } } if let age = viewModel.characterDetail?.age { Text(I18n.Chat.Character.age(age)) .appFont(size: 14, weight: .regular) .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) .appFont(size: 14, weight: .regular) .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?.translated?.name ?? viewModel.characterDetail?.name ?? "") .appFont(size: 26, weight: .bold) .foregroundColor(.white) .lineLimit(1) .truncationMode(.tail) if let characterType = viewModel.characterDetail?.characterType { HStack(spacing: 8) { Text(characterType == .Clone ? I18n.Chat.Character.typeClone : I18n.Chat.Character.typeCharacter) .appFont(size: 12, weight: .regular) .foregroundColor(.white) .padding(.horizontal, 5) .padding(.vertical, 1) .background(characterType == .Clone ? Color(hex: "0020C9") : Color(hex: "009D68")) .cornerRadius(6) } } } // 설명 Text(viewModel.characterDetail?.translated?.description ?? viewModel.characterDetail?.description ?? "") .appFont(size: 18, weight: .regular) .foregroundColor(Color(hex: "B0BEC5")) Text(viewModel.characterDetail?.translated?.tags ?? viewModel.characterDetail?.tags ?? "") .appFont(size: 14, weight: .regular) .foregroundColor(Color(hex: "3BB9F1")) .multilineTextAlignment(.leading) } .padding(.horizontal, 24) } } // MARK: - World View Section extension CharacterDetailView { private func worldViewSection(backgrounds: String) -> some View { VStack(alignment: .leading, spacing: 8) { HStack { Text(I18n.Chat.Character.detailWorldViewTitle) .appFont(size: 18, weight: .bold) .foregroundColor(.white) Spacer() } CharacterExpandableTextView(text: backgrounds) } .padding(.horizontal, 24) } } // MARK: - Original Work Section extension CharacterDetailView { private func originalWorkSection(title: String, link: String) -> some View { VStack(spacing: 8) { HStack { Text(I18n.Chat.Character.detailOriginalTitle) .appFont(size: 16, weight: .bold) .fontWeight(.bold) .foregroundColor(.white) Spacer() } HStack { Text(title) .appFont(size: 16, weight: .regular) .foregroundColor(Color(hex: "B0BEC5")) Spacer() } Button(action: { if let url = URL(string: link) { UIApplication.shared.open(url) } }) { Text(I18n.Chat.Character.detailOriginalLinkButton) .appFont(size: 16, weight: .bold) .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: String) -> some View { VStack(alignment: .leading, spacing: 8) { HStack { Text(I18n.Chat.Character.detailPersonalityTitle) .appFont(size: 18, weight: .bold) .foregroundColor(.white) Spacer() } CharacterExpandableTextView(text: personalities) // 캐릭터톡 대화 가이드 VStack(alignment: .leading, spacing: 16) { HStack { Text(I18n.Chat.Character.detailConversationGuideTitle) .appFont(size: 16, weight: .bold) .foregroundColor(Color(hex: "B0BEC5")) Spacer() } Text(I18n.Chat.Character.detailConversationGuideDescription1) .appFont(size: 16, weight: .regular) .foregroundColor(Color(hex: "AEAEB2")) .multilineTextAlignment(.leading) Text(I18n.Chat.Character.detailConversationGuideDescription2) .appFont(size: 16, weight: .regular) .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(I18n.Chat.Character.detailChatButton) .appFont(size: 18, weight: .bold) .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) .appFont(size: 16, weight: .regular) .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") .appFont(size: 16) .foregroundColor(Color(hex: "607D8B")) .rotationEffect(.degrees(isExpanded ? 180 : 0)) Text(isExpanded ? I18n.Chat.Character.detailCollapse : I18n.Chat.Character.detailExpand) .appFont(size: 16, weight: .regular) .foregroundColor(Color(hex: "607D8B")) } .onTapGesture { withAnimation(.easeInOut(duration: 0.3)) { isExpanded.toggle() } } Spacer() } } } } }