feat(character-detail): 상세 화면 도입 및 네비게이션/API 연동
This commit is contained in:
		@@ -161,4 +161,6 @@ enum AppStep {
 | 
				
			|||||||
    case pointStatus(refresh: () -> Void)
 | 
					    case pointStatus(refresh: () -> Void)
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    case audition
 | 
					    case audition
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    case characterDetail(characterId: Int)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,6 +10,7 @@ import Moya
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
enum CharacterApi {
 | 
					enum CharacterApi {
 | 
				
			||||||
    case getCharacterHome
 | 
					    case getCharacterHome
 | 
				
			||||||
 | 
					    case getCharacterDetail(characterId: Int)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
extension CharacterApi: TargetType {
 | 
					extension CharacterApi: TargetType {
 | 
				
			||||||
@@ -19,6 +20,9 @@ extension CharacterApi: TargetType {
 | 
				
			|||||||
        switch self {
 | 
					        switch self {
 | 
				
			||||||
        case .getCharacterHome:
 | 
					        case .getCharacterHome:
 | 
				
			||||||
            return "/api/chat/character/main"
 | 
					            return "/api/chat/character/main"
 | 
				
			||||||
 | 
					            
 | 
				
			||||||
 | 
					        case .getCharacterDetail(let characterId):
 | 
				
			||||||
 | 
					            return "/api/chat/character/\(characterId)"
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,7 +19,7 @@ struct CharacterView: View {
 | 
				
			|||||||
                    // 배너
 | 
					                    // 배너
 | 
				
			||||||
                    if !viewModel.banners.isEmpty {
 | 
					                    if !viewModel.banners.isEmpty {
 | 
				
			||||||
                        AutoSlideCharacterBannerView(items: viewModel.banners) { banner in
 | 
					                        AutoSlideCharacterBannerView(items: viewModel.banners) { banner in
 | 
				
			||||||
                            DEBUG_LOG("Banner tapped: \(banner.characterId)")
 | 
					                            AppState.shared.setAppStep(step: .characterDetail(characterId: banner.characterId))
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                    
 | 
					                    
 | 
				
			||||||
@@ -29,7 +29,7 @@ struct CharacterView: View {
 | 
				
			|||||||
                            titleCount: viewModel.recentCharacters.count,
 | 
					                            titleCount: viewModel.recentCharacters.count,
 | 
				
			||||||
                            items: viewModel.recentCharacters
 | 
					                            items: viewModel.recentCharacters
 | 
				
			||||||
                        ) { ch in
 | 
					                        ) { ch in
 | 
				
			||||||
                            DEBUG_LOG("Recent tapped: \(ch.characterId)")
 | 
					                            AppState.shared.setAppStep(step: .characterDetail(characterId: ch.characterId))
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                    
 | 
					                    
 | 
				
			||||||
@@ -39,7 +39,7 @@ struct CharacterView: View {
 | 
				
			|||||||
                            title: "신규 캐릭터",
 | 
					                            title: "신규 캐릭터",
 | 
				
			||||||
                            items: viewModel.newCharacters
 | 
					                            items: viewModel.newCharacters
 | 
				
			||||||
                        ) { ch in
 | 
					                        ) { ch in
 | 
				
			||||||
                            DEBUG_LOG("New tapped: \(ch.characterId)")
 | 
					                            AppState.shared.setAppStep(step: .characterDetail(characterId: ch.characterId))
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                    
 | 
					                    
 | 
				
			||||||
@@ -52,7 +52,7 @@ struct CharacterView: View {
 | 
				
			|||||||
                                    title: section.title,
 | 
					                                    title: section.title,
 | 
				
			||||||
                                    items: section.characters
 | 
					                                    items: section.characters
 | 
				
			||||||
                                ) { ch in
 | 
					                                ) { ch in
 | 
				
			||||||
                                    DEBUG_LOG("Curation tapped: \\(ch.characterId)")
 | 
					                                    AppState.shared.setAppStep(step: .characterDetail(characterId: ch.characterId))
 | 
				
			||||||
                                }
 | 
					                                }
 | 
				
			||||||
                            }
 | 
					                            }
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					//
 | 
				
			||||||
 | 
					//  CharacterCommentResponse.swift
 | 
				
			||||||
 | 
					//  SodaLive
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//  Created by klaus on 9/1/25.
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct CharacterCommentResponse: Decodable {
 | 
				
			||||||
 | 
					    let commentId: Int
 | 
				
			||||||
 | 
					    let memberId: Int
 | 
				
			||||||
 | 
					    let memberProfileImage: String
 | 
				
			||||||
 | 
					    let memberNickname: String
 | 
				
			||||||
 | 
					    let createdAt: Int64
 | 
				
			||||||
 | 
					    let replyCount: Int
 | 
				
			||||||
 | 
					    let comment: String
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					//
 | 
				
			||||||
 | 
					//  CharacterDetailRepository.swift
 | 
				
			||||||
 | 
					//  SodaLive
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//  Created by klaus on 9/1/25.
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					import Foundation
 | 
				
			||||||
 | 
					import CombineMoya
 | 
				
			||||||
 | 
					import Combine
 | 
				
			||||||
 | 
					import Moya
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CharacterDetailRepository {
 | 
				
			||||||
 | 
					    private let characterApi = MoyaProvider<CharacterApi>()
 | 
				
			||||||
 | 
					    private let talkApi = MoyaProvider<TalkApi>()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    func getCharacterDetail(characterId: Int) -> AnyPublisher<Response, MoyaError> {
 | 
				
			||||||
 | 
					        return characterApi.requestPublisher(.getCharacterDetail(characterId: characterId))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,44 @@
 | 
				
			|||||||
 | 
					//
 | 
				
			||||||
 | 
					//  CharacterDetailResponse.swift
 | 
				
			||||||
 | 
					//  SodaLive
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//  Created by klaus on 9/1/25.
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct CharacterDetailResponse: Decodable {
 | 
				
			||||||
 | 
					    let characterId: Int
 | 
				
			||||||
 | 
					    let name: String
 | 
				
			||||||
 | 
					    let description: String
 | 
				
			||||||
 | 
					    let mbti: String?
 | 
				
			||||||
 | 
					    let imageUrl: String
 | 
				
			||||||
 | 
					    let personalities: CharacterPersonalityResponse?
 | 
				
			||||||
 | 
					    let backgrounds: CharacterBackgroundResponse?
 | 
				
			||||||
 | 
					    let tags: String
 | 
				
			||||||
 | 
					    let originalTitle: String?
 | 
				
			||||||
 | 
					    let originalLink: String?
 | 
				
			||||||
 | 
					    let characterType: CharacterType
 | 
				
			||||||
 | 
					    let others: [OtherCharacter]
 | 
				
			||||||
 | 
					    let latestComment: CharacterCommentResponse?
 | 
				
			||||||
 | 
					    let totalComments: Int
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					enum CharacterType: String, Decodable {
 | 
				
			||||||
 | 
					    case Clone, Character
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct OtherCharacter: Decodable {
 | 
				
			||||||
 | 
					    let characterId: Int
 | 
				
			||||||
 | 
					    let name: String
 | 
				
			||||||
 | 
					    let imageUrl: String
 | 
				
			||||||
 | 
					    let tags: String
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct CharacterPersonalityResponse: Decodable {
 | 
				
			||||||
 | 
					    let trait: String
 | 
				
			||||||
 | 
					    let description: String
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct CharacterBackgroundResponse: Decodable {
 | 
				
			||||||
 | 
					    let topic: String
 | 
				
			||||||
 | 
					    let description: String
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -8,11 +8,396 @@
 | 
				
			|||||||
import SwiftUI
 | 
					import SwiftUI
 | 
				
			||||||
 | 
					
 | 
				
			||||||
struct CharacterDetailView: View {
 | 
					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 {
 | 
					    var body: some View {
 | 
				
			||||||
        Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
 | 
					        BaseView(isLoading: $viewModel.isLoading) {
 | 
				
			||||||
 | 
					            VStack(spacing: 0) {
 | 
				
			||||||
 | 
					                DetailNavigationBar(title: viewModel.characterDetail?.name ?? "캐릭터 정보")
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                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()
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    
 | 
				
			||||||
 | 
					                    // 대화하기 버튼
 | 
				
			||||||
 | 
					                    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 {
 | 
				
			||||||
 | 
					        // 배경 이미지
 | 
				
			||||||
 | 
					        AsyncImage(url: URL(string: viewModel.characterDetail?.imageUrl ?? "https://picsum.photos/400")) { image in
 | 
				
			||||||
 | 
					            image
 | 
				
			||||||
 | 
					                .resizable()
 | 
				
			||||||
 | 
					                .aspectRatio(contentMode: .fill)
 | 
				
			||||||
 | 
					        } placeholder: {
 | 
				
			||||||
 | 
					            Rectangle()
 | 
				
			||||||
 | 
					                .fill(Color.gray)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        .frame(maxWidth: .infinity)
 | 
				
			||||||
 | 
					        .frame(height: screenSize().width)
 | 
				
			||||||
 | 
					        .clipped()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// MARK: - Profile Section
 | 
				
			||||||
 | 
					extension CharacterDetailView {
 | 
				
			||||||
 | 
					    private var profileSection: some View {
 | 
				
			||||||
 | 
					        VStack(alignment: .leading, spacing: 8) {
 | 
				
			||||||
 | 
					            // 이름과 상태
 | 
				
			||||||
 | 
					            HStack(spacing: 8) {
 | 
				
			||||||
 | 
					                Text(viewModel.characterDetail?.name ?? "")
 | 
				
			||||||
 | 
					                    .font(.custom(Font.preBold.rawValue, size: 26))
 | 
				
			||||||
 | 
					                    .foregroundColor(.white)
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                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 {
 | 
				
			||||||
 | 
					                // TODO: 대화하기 액션
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#Preview {
 | 
					#Preview {
 | 
				
			||||||
    CharacterDetailView()
 | 
					    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()
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,70 @@
 | 
				
			|||||||
 | 
					//
 | 
				
			||||||
 | 
					//  CharacterDetailViewModel.swift
 | 
				
			||||||
 | 
					//  SodaLive
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//  Created by klaus on 9/1/25.
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					import Foundation
 | 
				
			||||||
 | 
					import Combine
 | 
				
			||||||
 | 
					import Moya
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final class CharacterDetailViewModel: ObservableObject {
 | 
				
			||||||
 | 
					    // MARK: - Published State
 | 
				
			||||||
 | 
					    @Published private(set) var characterDetail: CharacterDetailResponse?
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    @Published var isLoading: Bool = false
 | 
				
			||||||
 | 
					    @Published var errorMessage: String = ""
 | 
				
			||||||
 | 
					    @Published var isShowPopup = false
 | 
				
			||||||
 | 
					    @Published var characterId: Int = 0 {
 | 
				
			||||||
 | 
					        didSet {
 | 
				
			||||||
 | 
					            if characterId > 0 {
 | 
				
			||||||
 | 
					                getCharacterDetail(characterId: characterId)
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // MARK: - Private
 | 
				
			||||||
 | 
					    private let repository = CharacterDetailRepository()
 | 
				
			||||||
 | 
					    private var subscription = Set<AnyCancellable>()
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    // MARK: - Public Methods
 | 
				
			||||||
 | 
					    func getCharacterDetail(characterId: Int) {
 | 
				
			||||||
 | 
					        isLoading = true
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        repository.getCharacterDetail(characterId: characterId)
 | 
				
			||||||
 | 
					            .receive(on: DispatchQueue.main)
 | 
				
			||||||
 | 
					            .sink { result in
 | 
				
			||||||
 | 
					                switch result {
 | 
				
			||||||
 | 
					                case .finished:
 | 
				
			||||||
 | 
					                    DEBUG_LOG("finish")
 | 
				
			||||||
 | 
					                case .failure(let error):
 | 
				
			||||||
 | 
					                    ERROR_LOG(error.localizedDescription)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            } receiveValue: { [weak self] response in
 | 
				
			||||||
 | 
					                let responseData = response.data
 | 
				
			||||||
 | 
					                self?.isLoading = false
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
 | 
					                do {
 | 
				
			||||||
 | 
					                    let jsonDecoder = JSONDecoder()
 | 
				
			||||||
 | 
					                    let decoded = try jsonDecoder.decode(ApiResponse<CharacterDetailResponse>.self, from: responseData)
 | 
				
			||||||
 | 
					                    
 | 
				
			||||||
 | 
					                    if let data = decoded.data, decoded.success {
 | 
				
			||||||
 | 
					                        self?.characterDetail = data
 | 
				
			||||||
 | 
					                    } else {
 | 
				
			||||||
 | 
					                        if let message = decoded.message {
 | 
				
			||||||
 | 
					                            self?.errorMessage = message
 | 
				
			||||||
 | 
					                        } else {
 | 
				
			||||||
 | 
					                            self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                        
 | 
				
			||||||
 | 
					                        self?.isShowPopup = true
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                } catch {
 | 
				
			||||||
 | 
					                    ERROR_LOG(String(describing: error))
 | 
				
			||||||
 | 
					                    self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
 | 
				
			||||||
 | 
					                    self?.isShowPopup = true
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            .store(in: &subscription)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -0,0 +1,18 @@
 | 
				
			|||||||
 | 
					//
 | 
				
			||||||
 | 
					//  CharacterDetailGalleryView.swift
 | 
				
			||||||
 | 
					//  SodaLive
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					//  Created by klaus on 9/1/25.
 | 
				
			||||||
 | 
					//
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import SwiftUI
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					struct CharacterDetailGalleryView: View {
 | 
				
			||||||
 | 
					    var body: some View {
 | 
				
			||||||
 | 
					        Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#Preview {
 | 
				
			||||||
 | 
					    CharacterDetailGalleryView()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -248,6 +248,9 @@ struct ContentView: View {
 | 
				
			|||||||
            case .audition:
 | 
					            case .audition:
 | 
				
			||||||
                AuditionView()
 | 
					                AuditionView()
 | 
				
			||||||
                
 | 
					                
 | 
				
			||||||
 | 
					            case .characterDetail(let characterId):
 | 
				
			||||||
 | 
					                CharacterDetailView(characterId: characterId)
 | 
				
			||||||
 | 
					                
 | 
				
			||||||
            default:
 | 
					            default:
 | 
				
			||||||
                EmptyView()
 | 
					                EmptyView()
 | 
				
			||||||
                    .frame(width: 0, height: 0, alignment: .topLeading)
 | 
					                    .frame(width: 0, height: 0, alignment: .topLeading)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,14 +14,14 @@ struct ExpandableTextView: View {
 | 
				
			|||||||
    let text: String
 | 
					    let text: String
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    var body: some View {
 | 
					    var body: some View {
 | 
				
			||||||
        let customFont = UIFont(name: Font.medium.rawValue, size: 12.3) ?? UIFont.systemFont(ofSize: 12.3)
 | 
					        let customFont = UIFont(name: Font.preRegular.rawValue, size: 12.3) ?? UIFont.systemFont(ofSize: 12.3)
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        let lineSpacing = CGFloat(5)
 | 
					        let lineSpacing = CGFloat(5)
 | 
				
			||||||
        let lineHeight = customFont.lineHeight + lineSpacing
 | 
					        let lineHeight = customFont.lineHeight + lineSpacing
 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        VStack(alignment: .leading) {
 | 
					        VStack(alignment: .leading) {
 | 
				
			||||||
            Text(text)
 | 
					            Text(text)
 | 
				
			||||||
                .font(.custom(Font.medium.rawValue, size: 14))
 | 
					                .font(.custom(Font.preRegular.rawValue, size: 14))
 | 
				
			||||||
                .foregroundColor(Color.gray77)
 | 
					                .foregroundColor(Color.gray77)
 | 
				
			||||||
                .lineLimit(isExpanded ? nil : 3) // 확장 시 전체 표시, 아니면 3줄로 제한
 | 
					                .lineLimit(isExpanded ? nil : 3) // 확장 시 전체 표시, 아니면 3줄로 제한
 | 
				
			||||||
                .truncationMode(.tail)
 | 
					                .truncationMode(.tail)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -26,8 +26,8 @@ struct DetailNavigationBar: View {
 | 
				
			|||||||
                    .frame(width: 20, height: 20)
 | 
					                    .frame(width: 20, height: 20)
 | 
				
			||||||
                
 | 
					                
 | 
				
			||||||
                Text(title)
 | 
					                Text(title)
 | 
				
			||||||
                    .font(.custom(Font.bold.rawValue, size: 18.3))
 | 
					                    .font(.custom(Font.preBold.rawValue, size: 18.3))
 | 
				
			||||||
                    .foregroundColor(Color(hex: "eeeeee"))
 | 
					                    .foregroundColor(.grayee)
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            
 | 
					            
 | 
				
			||||||
            Spacer()
 | 
					            Spacer()
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user