feat(explorer): 크리에이터 상세정보 다이얼로그와 SNS 링크를 추가한다

This commit is contained in:
Yu Sung
2026-02-25 16:28:48 +09:00
parent 7ff9360b1e
commit e9bd1e7396
18 changed files with 449 additions and 7 deletions

View File

@@ -13,6 +13,7 @@ enum ExplorerApi {
case getExplorer
case searchChannel(channel: String)
case getCreatorProfile(userId: Int, isAdultContentVisible: Bool)
case getCreatorDetail(userId: Int)
case getFollowerList(userId: Int, page: Int, size: Int)
case getCreatorProfileCheers(userId: Int, page: Int, size: Int)
case writeCheers(parentCheersId: Int?, creatorId: Int, content: String)
@@ -39,6 +40,9 @@ extension ExplorerApi: TargetType {
case .getCreatorProfile(let userId, _):
return "/explorer/profile/\(userId)"
case .getCreatorDetail(let userId):
return "/explorer/profile/\(userId)/detail"
case .getCreatorProfileDonationRanking(let userId, _, _, _):
return "/explorer/profile/\(userId)/donation-rank"
@@ -62,7 +66,7 @@ extension ExplorerApi: TargetType {
var method: Moya.Method {
switch self {
case .getExplorer, .searchChannel, .getCreatorProfile, .getFollowerList, .getCreatorProfileCheers, .getCreatorProfileDonationRanking, .getCreatorRank:
case .getExplorer, .searchChannel, .getCreatorProfile, .getCreatorDetail, .getFollowerList, .getCreatorProfileCheers, .getCreatorProfileDonationRanking, .getCreatorRank:
return .get
case .writeCheers, .writeCreatorNotice:
@@ -75,7 +79,7 @@ extension ExplorerApi: TargetType {
var task: Task {
switch self {
case .getExplorer, .getCreatorRank:
case .getExplorer, .getCreatorRank, .getCreatorDetail:
return .requestPlain
case .searchChannel(let channel):

View File

@@ -29,6 +29,10 @@ final class ExplorerRepository {
)
)
}
func getCreatorDetail(id: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getCreatorDetail(userId: id))
}
func getFollowerList(userId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getFollowerList(userId: userId, page: page, size: size))

View File

@@ -0,0 +1,217 @@
//
// CreatorDetailDialogView.swift
// SodaLive
//
// Created by klaus on 2/25/26.
//
import SwiftUI
import UIKit
import Kingfisher
struct CreatorDetailDialogView: View {
@Binding var isShowing: Bool
let creatorDetail: GetCreatorDetailResponse?
let isLoading: Bool
@Environment(\.openURL) private var openURL
private var closeIconAssetName: String {
UIImage(named: "ic_x_white") != nil ? "ic_x_white" : "ic_close_white"
}
private var profileImageSize: CGFloat {
screenSize().width - 26.7 - 48
}
private var snsItems: [CreatorDetailSnsItem] {
guard let creatorDetail else { return [] }
var items = [CreatorDetailSnsItem]()
appendSnsItem(items: &items, iconName: "ic_sns_instagram", url: creatorDetail.instagramUrl)
appendSnsItem(items: &items, iconName: "ic_sns_fancimm", url: creatorDetail.fancimmUrl)
appendSnsItem(items: &items, iconName: "ic_sns_x", url: creatorDetail.xurl)
appendSnsItem(items: &items, iconName: "ic_sns_youtube", url: creatorDetail.youtubeUrl)
appendSnsItem(items: &items, iconName: "ic_sns_kakao", url: creatorDetail.kakaoOpenChatUrl)
return items
}
var body: some View {
ZStack {
Color.black
.opacity(0.7)
.ignoresSafeArea()
.onTapGesture {
isShowing = false
}
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
Spacer()
Image(closeIconAssetName)
.resizable()
.frame(width: 24, height: 24)
.contentShape(Rectangle())
.onTapGesture {
isShowing = false
}
}
.padding(.top, 24)
.padding(.trailing, 24)
VStack(alignment: .leading, spacing: 0) {
if isLoading {
LoadingView()
.frame(maxWidth: .infinity)
.frame(height: 240)
} else if let creatorDetail {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
KFImage(URL(string: creatorDetail.profileImageUrl))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: profileImageSize, height: profileImageSize))
.resizable()
.scaledToFill()
.frame(width: profileImageSize, height: profileImageSize)
.clipped()
.cornerRadius(16)
Text(creatorDetail.nickname)
.appFont(size: 36, weight: .bold)
.foregroundColor(.white)
.padding(.top, 24)
VStack(alignment: .leading, spacing: 30) {
detailSection(
title: I18n.MemberChannel.creatorDetailDebut,
value: debutDisplayValue(creatorDetail: creatorDetail)
)
detailSection(
title: I18n.MemberChannel.creatorDetailTotalLiveCount,
value: creatorDetail.activitySummary.liveCount.comma()
)
detailSection(
title: I18n.MemberChannel.creatorDetailAccumulatedLiveTime,
value: creatorDetail.activitySummary.liveTime.comma()
)
detailSection(
title: I18n.MemberChannel.creatorDetailAccumulatedParticipants,
value: creatorDetail.activitySummary.liveContributorCount.comma()
)
detailSection(
title: I18n.MemberChannel.creatorDetailRegisteredContentCount,
value: creatorDetail.activitySummary.contentCount.comma()
)
if !snsItems.isEmpty {
snsSection(items: snsItems)
}
}
.padding(.top, 30)
}
}
.frame(maxHeight: screenSize().height * 0.72)
}
}
.padding(.horizontal, 24)
.padding(.top, 12)
.padding(.bottom, 24)
}
.background(Color.gray22)
.cornerRadius(8)
.padding(.horizontal, 13.3)
}
}
@ViewBuilder
private func detailSection(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.appFont(size: 16, weight: .medium)
.foregroundColor(Color(hex: "B0BEC5"))
Text(value)
.appFont(size: 20, weight: .medium)
.foregroundColor(.white)
}
}
@ViewBuilder
private func snsSection(items: [CreatorDetailSnsItem]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(I18n.MemberChannel.creatorDetailSns)
.appFont(size: 16, weight: .medium)
.foregroundColor(Color(hex: "B0BEC5"))
HStack(spacing: 12) {
ForEach(items) { item in
Image(item.iconName)
.resizable()
.frame(width: 32, height: 32)
.contentShape(Rectangle())
.onTapGesture {
openSnsLink(item.url)
}
}
}
}
}
private func debutDisplayValue(creatorDetail: GetCreatorDetailResponse) -> String {
let debutDate = creatorDetail.debutDate.trimmingCharacters(in: .whitespacesAndNewlines)
let dday = creatorDetail.dday.trimmingCharacters(in: .whitespacesAndNewlines)
guard !debutDate.isEmpty, !dday.isEmpty else {
return I18n.MemberChannel.preDebut
}
return "\(debutDate) (\(dday))"
}
private func appendSnsItem(items: inout [CreatorDetailSnsItem], iconName: String, url: String) {
let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return
}
items.append(CreatorDetailSnsItem(iconName: iconName, url: trimmed))
}
private func openSnsLink(_ urlString: String) {
guard let url = normalizedUrl(urlString) else {
return
}
openURL(url)
}
private func normalizedUrl(_ urlString: String) -> URL? {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return nil
}
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
return URL(string: trimmed)
}
return URL(string: "https://\(trimmed)")
}
}
private struct CreatorDetailSnsItem: Identifiable {
let iconName: String
let url: String
var id: String { iconName }
}

View File

@@ -0,0 +1,26 @@
//
// GetCreatorDetailResponse.swift
// SodaLive
//
// Created by klaus on 2/25/26.
//
struct GetCreatorDetailResponse: Decodable {
let nickname: String
let profileImageUrl: String
let debutDate: String
let dday: String
let activitySummary: CreatorDetailActivitySummary
let instagramUrl: String
let fancimmUrl: String
let xurl: String
let youtubeUrl: String
let kakaoOpenChatUrl: String
}
struct CreatorDetailActivitySummary: Decodable {
let liveCount: Int
let liveTime: Int
let liveContributorCount: Int
let contentCount: Int
}

View File

@@ -21,6 +21,7 @@ struct UserProfileView: View {
@State private var isShowFollowNotifyDialog: Bool = false
@State private var isShowRouletteSettings: Bool = false
@State private var isShowMenuSettings: Bool = false
@State private var isShowCreatorDetailDialog: Bool = false
@State private var maxCommunityPostHeight: CGFloat? = nil
@@ -79,11 +80,13 @@ struct UserProfileView: View {
AppState.shared.setAppStep(step: .followerList(userId: creatorProfile.creator.creatorId))
}
} else {
VStack(alignment: .leading, spacing: 9.3) {
Text(I18n.MemberChannel.followerCount(creatorProfile.creator.notificationRecipientCount.comma()))
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.white)
}
Text(I18n.MemberChannel.followerCountWithDetail(creatorProfile.creator.notificationRecipientCount.comma()))
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.white)
.onTapGesture {
isShowCreatorDetailDialog = true
viewModel.getCreatorDetail(userId: creatorProfile.creator.creatorId)
}
}
}
.padding(24)
@@ -517,6 +520,14 @@ struct UserProfileView: View {
if isShowMemberProfilePopup {
MemberProfileDialog(isShowing: $isShowMemberProfilePopup, memberId: memberId)
}
if isShowCreatorDetailDialog {
CreatorDetailDialogView(
isShowing: $isShowCreatorDetailDialog,
creatorDetail: viewModel.creatorDetail,
isLoading: viewModel.isCreatorDetailLoading
)
}
}
ZStack {

View File

@@ -35,6 +35,8 @@ final class UserProfileViewModel: ObservableObject {
@Published var navigationTitle = "채널"
@Published private(set) var creatorProfile: GetCreatorProfileResponse?
@Published private(set) var creatorDetail: GetCreatorDetailResponse?
@Published var isCreatorDetailLoading = false
@Published private(set) var communityPostList = [GetCommunityPostListResponse]()
@Published var isShowShareView = false
@@ -97,6 +99,47 @@ final class UserProfileViewModel: ObservableObject {
}
.store(in: &subscription)
}
func getCreatorDetail(userId: Int) {
creatorDetail = nil
isCreatorDetailLoading = true
repository.getCreatorDetail(id: userId)
.sink { [weak self] result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self?.isCreatorDetailLoading = false
}
} receiveValue: { [unowned self] response in
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<GetCreatorDetailResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.creatorDetail = data
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = I18n.Common.commonError
}
self.isShowPopup = true
}
} catch {
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.isCreatorDetailLoading = false
}
.store(in: &subscription)
}
func hidePaymentPopup() {
isShowPaymentDialog = false

View File

@@ -884,6 +884,16 @@ enum I18n {
static var followerCount: (String) -> String = { count in
pick(ko: "팔로워 \(count)", en: "\(count) followers", ja: "フォロワー\(count)")
}
static var followerCountWithDetail: (String) -> String = { count in
pick(ko: "팔로워 \(count)명 · 상세정보 >", en: "\(count) followers · Details >", ja: "フォロワー\(count)人 ・ 詳細情報 >")
}
static var creatorDetailDebut: String { pick(ko: "데뷔", en: "Debut", ja: "デビュー") }
static var creatorDetailTotalLiveCount: String { pick(ko: "라이브 총 횟수", en: "Total live sessions", ja: "ライブ総回数") }
static var creatorDetailAccumulatedLiveTime: String { pick(ko: "라이브 누적 시간", en: "Total live time", ja: "ライブ累積時間") }
static var creatorDetailAccumulatedParticipants: String { pick(ko: "라이브 누적 참여자", en: "Total live participants", ja: "ライブ累積参加者") }
static var creatorDetailRegisteredContentCount: String { pick(ko: "등록 콘텐츠 수", en: "Registered contents", ja: "登録コンテンツ数") }
static var creatorDetailSns: String { pick(ko: "SNS", en: "SNS", ja: "SNS") }
static var preDebut: String { pick(ko: "데뷔전", en: "Pre-debut", ja: "デビュー前") }
static func channelTitle(_ nickname: String) -> String {
pick(ko: "\(nickname)님의 채널", en: "\(nickname)'s channel", ja: "\(nickname)のチャンネル")