Files
sodalive-ios/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift

470 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
@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 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()
}
}
}
}
}