485 lines
20 KiB
Swift
485 lines
20 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
|
|
|
|
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|