From f11120b8d0755d55bf7acae3e4614d7f5da79b7d Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 2 Sep 2025 01:10:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(character-detail):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=EB=84=A4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98/API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/App/AppStep.swift | 2 + .../Sources/Chat/Character/CharacterApi.swift | 4 + .../Chat/Character/CharacterView.swift | 8 +- .../Comment/CharacterCommentResponse.swift | 16 + .../Detail/CharacterDetailRepository.swift | 19 + .../Detail/CharacterDetailResponse.swift | 44 ++ .../Detail/CharacterDetailView.swift | 389 +++++++++++++++++- .../Detail/CharacterDetailViewModel.swift | 70 ++++ .../Gallery/CharacterDetailGalleryView.swift | 18 + SodaLive/Sources/ContentView.swift | 3 + .../CustomView/ExpandableTextView.swift | 4 +- .../NavigationBar/DetailNavigationBar.swift | 4 +- 12 files changed, 571 insertions(+), 10 deletions(-) create mode 100644 SodaLive/Sources/Chat/Character/Comment/CharacterCommentResponse.swift create mode 100644 SodaLive/Sources/Chat/Character/Detail/CharacterDetailRepository.swift create mode 100644 SodaLive/Sources/Chat/Character/Detail/CharacterDetailResponse.swift create mode 100644 SodaLive/Sources/Chat/Character/Detail/CharacterDetailViewModel.swift create mode 100644 SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 65ffe19..7e0eed7 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -161,4 +161,6 @@ enum AppStep { case pointStatus(refresh: () -> Void) case audition + + case characterDetail(characterId: Int) } diff --git a/SodaLive/Sources/Chat/Character/CharacterApi.swift b/SodaLive/Sources/Chat/Character/CharacterApi.swift index cc8af3e..464f6df 100644 --- a/SodaLive/Sources/Chat/Character/CharacterApi.swift +++ b/SodaLive/Sources/Chat/Character/CharacterApi.swift @@ -10,6 +10,7 @@ import Moya enum CharacterApi { case getCharacterHome + case getCharacterDetail(characterId: Int) } extension CharacterApi: TargetType { @@ -19,6 +20,9 @@ extension CharacterApi: TargetType { switch self { case .getCharacterHome: return "/api/chat/character/main" + + case .getCharacterDetail(let characterId): + return "/api/chat/character/\(characterId)" } } diff --git a/SodaLive/Sources/Chat/Character/CharacterView.swift b/SodaLive/Sources/Chat/Character/CharacterView.swift index 1da5ffa..2114403 100644 --- a/SodaLive/Sources/Chat/Character/CharacterView.swift +++ b/SodaLive/Sources/Chat/Character/CharacterView.swift @@ -19,7 +19,7 @@ struct CharacterView: View { // 배너 if !viewModel.banners.isEmpty { 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, items: viewModel.recentCharacters ) { ch in - DEBUG_LOG("Recent tapped: \(ch.characterId)") + AppState.shared.setAppStep(step: .characterDetail(characterId: ch.characterId)) } } @@ -39,7 +39,7 @@ struct CharacterView: View { title: "신규 캐릭터", items: viewModel.newCharacters ) { 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, items: section.characters ) { ch in - DEBUG_LOG("Curation tapped: \\(ch.characterId)") + AppState.shared.setAppStep(step: .characterDetail(characterId: ch.characterId)) } } } diff --git a/SodaLive/Sources/Chat/Character/Comment/CharacterCommentResponse.swift b/SodaLive/Sources/Chat/Character/Comment/CharacterCommentResponse.swift new file mode 100644 index 0000000..7c91ef5 --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Comment/CharacterCommentResponse.swift @@ -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 +} diff --git a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailRepository.swift b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailRepository.swift new file mode 100644 index 0000000..dc059aa --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailRepository.swift @@ -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() + private let talkApi = MoyaProvider() + + func getCharacterDetail(characterId: Int) -> AnyPublisher { + return characterApi.requestPublisher(.getCharacterDetail(characterId: characterId)) + } +} diff --git a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailResponse.swift b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailResponse.swift new file mode 100644 index 0000000..1aa935a --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailResponse.swift @@ -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 +} diff --git a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift index 3fc79df..cb4f04a 100644 --- a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift +++ b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift @@ -8,11 +8,396 @@ import SwiftUI 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 { - 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 { - 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() + } + } + } + } } diff --git a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailViewModel.swift b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailViewModel.swift new file mode 100644 index 0000000..0396007 --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailViewModel.swift @@ -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() + + // 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.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) + } +} diff --git a/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift b/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift new file mode 100644 index 0000000..3786a26 --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Detail/Gallery/CharacterDetailGalleryView.swift @@ -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() +} diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 13302e3..e417dc5 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -248,6 +248,9 @@ struct ContentView: View { case .audition: AuditionView() + case .characterDetail(let characterId): + CharacterDetailView(characterId: characterId) + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading) diff --git a/SodaLive/Sources/CustomView/ExpandableTextView.swift b/SodaLive/Sources/CustomView/ExpandableTextView.swift index 2518a9a..0455b7c 100644 --- a/SodaLive/Sources/CustomView/ExpandableTextView.swift +++ b/SodaLive/Sources/CustomView/ExpandableTextView.swift @@ -14,14 +14,14 @@ struct ExpandableTextView: View { let text: String 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 lineHeight = customFont.lineHeight + lineSpacing VStack(alignment: .leading) { Text(text) - .font(.custom(Font.medium.rawValue, size: 14)) + .font(.custom(Font.preRegular.rawValue, size: 14)) .foregroundColor(Color.gray77) .lineLimit(isExpanded ? nil : 3) // 확장 시 전체 표시, 아니면 3줄로 제한 .truncationMode(.tail) diff --git a/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift b/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift index 1d4d0ba..e55bad0 100644 --- a/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift +++ b/SodaLive/Sources/NavigationBar/DetailNavigationBar.swift @@ -26,8 +26,8 @@ struct DetailNavigationBar: View { .frame(width: 20, height: 20) Text(title) - .font(.custom(Font.bold.rawValue, size: 18.3)) - .foregroundColor(Color(hex: "eeeeee")) + .font(.custom(Font.preBold.rawValue, size: 18.3)) + .foregroundColor(.grayee) } Spacer()