472 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			472 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
//
 | 
						|
//  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
 | 
						|
    
 | 
						|
    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: "캐릭터 정보")
 | 
						|
                
 | 
						|
                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
 | 
						|
                                                        )
 | 
						|
                                                        .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
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
            .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))
 | 
						|
                    .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()
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 |