import SwiftUI import Bootpay import BootpayUI struct CreatorChannelView: View { let creatorId: Int @StateObject private var viewModel = CreatorChannelViewModel() @StateObject private var channelDonationViewModel = ChannelDonationViewModel() @StateObject private var donationViewModel = CreatorChannelDonationViewModel() @StateObject private var fanTalkViewModel = CreatorChannelFanTalkViewModel() @StateObject private var communityViewModel = CreatorChannelCommunityViewModel() @StateObject private var liveViewModel = LiveViewModel() @StateObject private var mypageViewModel = MyPageViewModel() @AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token) @AppStorage("auth") private var auth: Bool = UserDefaults.bool(forKey: UserDefaultsKey.auth) @Environment(\.dismiss) private var dismiss @State private var tabBarMinY: CGFloat = .greatestFiniteMagnitude @State private var isShowChannelDonationDialog = false @State private var isShowAuthView = false @State private var isShowAuthConfirmView = false @State private var isCreatorActionMenuPresented = false @State private var pendingAction: (() -> Void)? = nil @State private var payload = Payload() private let titleBarHeight: CGFloat = 56 private let tabBarHeight: CGFloat = 52 private var isOwnCreatorChannel: Bool { guard let creatorId = viewModel.response?.creator.creatorId else { return false } let userId = UserDefaults.int(forKey: .userId) guard userId > 0 else { return false } return creatorId == userId } init(creatorId: Int, viewModel: CreatorChannelViewModel = CreatorChannelViewModel()) { self.creatorId = creatorId _viewModel = StateObject(wrappedValue: viewModel) } var body: some View { GeometryReader { proxy in let titleBarBottomY = proxy.safeAreaInsets.top + titleBarHeight let backgroundProgress = clamp(1 - ((tabBarMinY - titleBarBottomY) / titleBarHeight)) let isTabBarSticky = tabBarMinY <= titleBarBottomY ZStack(alignment: .top) { Color.black.ignoresSafeArea() ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) { headerSection tabBar .background( GeometryReader { geometry in let minY = geometry.frame(in: .global).minY Color.clear .onAppear { updateTabBarMinY(minY) } .onChange(of: minY) { value in updateTabBarMinY(value) } } ) .opacity(isTabBarSticky ? 0 : 1) selectedTabContent } } .ignoresSafeArea(.container, edges: .top) if isTabBarSticky { tabBar .frame(height: tabBarHeight) .offset(y: titleBarHeight) } VStack(spacing: 0) { Color.black .opacity(backgroundProgress) .frame(height: proxy.safeAreaInsets.top) titleBar(backgroundProgress: backgroundProgress) } .ignoresSafeArea(.container, edges: .top) if viewModel.isLoading { LoadingView() } if isOwnCreatorChannel && viewModel.selectedTab == .home && !viewModel.isLoading { CreatorChannelFloatingActionMenu( isPresented: $isCreatorActionMenuPresented, onTapCommunityPost: showCommunityWrite, onTapAudioContent: showAudioContentUpload, onTapCreateLive: showCreateLive ) } if isOwnCreatorChannel && viewModel.selectedTab == .live && !viewModel.isLoading { VStack { Spacer() CreatorChannelLiveStartButton(action: showCreateLive) } .ignoresSafeArea(.container, edges: .bottom) } if isOwnCreatorChannel && viewModel.selectedTab == .audio && !viewModel.isLoading { VStack { Spacer() CreatorChannelAudioUploadButton(action: showAudioContentUpload) } .ignoresSafeArea(.container, edges: .bottom) } if viewModel.selectedTab == .donation && shouldShowDonationFloatingButton && !viewModel.isLoading { CreatorChannelDonationFloatingButton(action: showDonationDialog) .ignoresSafeArea(.container, edges: .bottom) .padding(.bottom, SodaSpacing.s14) .padding(.trailing, SodaSpacing.s14) } if viewModel.selectedTab == .fanTalk && showFanTalkWrite && !viewModel.isLoading { VStack { Spacer() HStack { Spacer() CreatorChannelFloatingIconButton( imageName: "ic_plus_no_bg", backgroundColor: Color.soda400, accessibilityLabel: I18n.CreatorChannelHome.fanTalk, action: showFanTalkWriteView ) .padding(.trailing, SodaSpacing.s14) } } .ignoresSafeArea(.container, edges: .bottom) .padding(.bottom, SodaSpacing.s14) } if isOwnCreatorChannel && viewModel.selectedTab == .community && !viewModel.isLoading { VStack { Spacer() CreatorChannelCommunityUploadButton(action: showCommunityWrite) } .ignoresSafeArea(.container, edges: .bottom) } if isShowAuthConfirmView { authConfirmDialog } if viewModel.selectedTab == .fanTalk && fanTalkViewModel.isShowDeleteDialog { fanTalkDeleteDialog } if isShowChannelDonationDialog { LiveRoomDonationDialogView( isShowing: $isShowChannelDonationDialog, isAudioContentDonation: false, messageLimit: 100, secretLabel: I18n.MemberChannel.secretDonationLabel, secretMinimumCanMessage: I18n.MemberChannel.secretDonationMinimumCanMessage, shouldPrefixSecretInMessagePlaceholder: false, onClickDonation: { can, message, isSecret in channelDonationViewModel.postChannelDonation( can: can, message: message, isSecret: isSecret, reloadAfterSuccess: false, onSuccess: { viewModel.fetchHome(creatorId: creatorId) donationViewModel.fetchFirstPage(creatorId: creatorId) } ) } ) } communityOverlay(proxy: proxy) } } .navigationBarBackButtonHidden(true) .fullScreenCover(isPresented: $isShowAuthView) { authView } .onAppear { configurePayload() if viewModel.hasLoaded == false { viewModel.fetchHome(creatorId: creatorId) } } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1) .sodaToast(isPresented: $channelDonationViewModel.isShowPopup, message: channelDonationViewModel.errorMessage, autohideIn: 2) .onChange(of: viewModel.selectedTab) { selectedTab in if selectedTab != .home { isCreatorActionMenuPresented = false } } } private func titleBar(backgroundProgress: CGFloat) -> some View { CreatorChannelTitleBar( nickname: viewModel.response?.creator.nickname ?? "", isFollow: viewModel.response?.creator.isFollow ?? false, isNotify: viewModel.response?.creator.isNotify ?? false, backgroundProgress: backgroundProgress, showsCreatorActions: !isOwnCreatorChannel, onTapBack: { dismiss() }, onTapFollow: { viewModel.creatorFollow(follow: true, notify: true) }, onTapUnfollow: { viewModel.creatorFollow(follow: false, notify: false) }, onTapNotify: { viewModel.creatorFollow(follow: true, notify: true) }, onTapUnnotify: { viewModel.creatorFollow(follow: true, notify: false) }, onTapMore: {} ) } @ViewBuilder private var headerSection: some View { if let creator = viewModel.response?.creator { CreatorChannelHeaderSection( creator: creator, onTapTalk: handleTalkTap, onTapDm: handleDmTap ) } } private var tabBar: some View { CreatorChannelTabBar(selectedTab: $viewModel.selectedTab) } private func clamp(_ value: CGFloat) -> CGFloat { max(0, min(value, 1)) } private func updateTabBarMinY(_ value: CGFloat) { guard tabBarMinY != value else { return } DispatchQueue.main.async { tabBarMinY = value } } private var shouldShowDonationFloatingButton: Bool { isOwnCreatorChannel == false && (donationViewModel.donationCount != 0 || donationViewModel.rankings.isEmpty == false) } private var showFanTalkWrite: Bool { isOwnCreatorChannel == false && fanTalkViewModel.fanTalkCount > 0 } @ViewBuilder private var selectedTabContent: some View { if viewModel.selectedTab == .live { CreatorChannelLiveTabView( creatorId: creatorId, isOwnCreatorChannel: isOwnCreatorChannel, onTapLive: showLiveDetail, onTapContent: showContentDetail ) } else if viewModel.selectedTab == .audio { CreatorChannelAudioTabView( creatorId: creatorId, isOwnCreatorChannel: isOwnCreatorChannel, onTapContent: showContentDetail ) } else if viewModel.selectedTab == .series { CreatorChannelSeriesTabView( creatorId: creatorId, isOwnCreatorChannel: isOwnCreatorChannel, onTapSeries: showSeriesDetail ) } else if viewModel.selectedTab == .donation { CreatorChannelDonationTabView( creatorId: creatorId, isOwnCreatorChannel: isOwnCreatorChannel, viewModel: donationViewModel, onTapViewAll: showDonationRankingAll, onTapDonate: showDonationDialog ) } else if viewModel.selectedTab == .fanTalk { CreatorChannelFanTalkTabView( creatorId: creatorId, isOwnCreatorChannel: isOwnCreatorChannel, viewModel: fanTalkViewModel, onTapWrite: showFanTalkWriteView, onTapEdit: showFanTalkModifyView, onTapDetail: showFanTalkReplyDetail ) } else if viewModel.selectedTab == .community { CreatorChannelCommunityTabView( creatorId: creatorId, isOwnCreatorChannel: isOwnCreatorChannel, viewModel: communityViewModel, onTapDetail: showCommunityPostDetail ) } else if viewModel.selectedTab != .home { CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) } else if let response = viewModel.response { CreatorChannelHomeView( response: response, onSelectTab: selectTab, onTapLive: showLiveDetail, onTapContent: showContentDetail, onTapDonate: showDonationDialog, onTapSchedule: handleScheduleTap, onTapSeries: showSeriesDetail, onTapCommunityLike: viewModel.likeCommunityPost ) } else { EmptyView() } } private var authConfirmDialog: some View { SodaV2ActionModal( title: I18n.Chat.Auth.dialogTitle, message: I18n.Chat.Auth.dialogDescription, button1: SodaV2ActionModalButton( label: I18n.Chat.Auth.goToVerification, action: { isShowAuthConfirmView = false isShowAuthView = true } ), button2: SodaV2ActionModalButton( label: I18n.Common.cancel, action: dismissAuthConfirmDialog ), onDimmedTap: dismissAuthConfirmDialog ) } private func dismissAuthConfirmDialog() { isShowAuthConfirmView = false pendingAction = nil } private var fanTalkDeleteDialog: some View { SodaV2ActionModal( title: I18n.MemberChannel.cheersDeleteTitle, message: I18n.Common.confirmDeleteQuestion, button1: SodaV2ActionModalButton( label: I18n.Common.delete, action: { fanTalkViewModel.isShowDeleteDialog = false fanTalkViewModel.deleteSelectedFanTalk(creatorId: creatorId) } ), button2: SodaV2ActionModalButton( label: I18n.Common.cancel, action: dismissFanTalkDeleteDialog ), onDimmedTap: dismissFanTalkDeleteDialog ) } private func dismissFanTalkDeleteDialog() { fanTalkViewModel.isShowDeleteDialog = false fanTalkViewModel.selectedFanTalkId = 0 } private var authView: some View { BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) .onConfirm { _ in true } .onCancel { _ in isShowAuthView = false } .onError { _ in AppState.shared.errorMessage = I18n.Chat.Auth.authenticationError AppState.shared.isShowErrorPopup = true isShowAuthView = false } .onDone { DEBUG_LOG("onDone: \($0)") mypageViewModel.authVerify($0) { auth = true isShowAuthView = false if let action = pendingAction { pendingAction = nil action() } } } .onClose { isShowAuthView = false } } private func configurePayload() { payload.applicationId = BOOTPAY_APP_ID payload.price = 0 payload.pg = "다날" payload.method = "본인인증" payload.orderName = "본인인증" payload.authenticationId = "\(UserDefaults.string(forKey: .nickname))__\(String(NSTimeIntervalSince1970))" } private func selectTab(_ tab: CreatorChannelTab) { switch tab { case .audio: viewModel.selectedTab = .audio case .series: viewModel.selectedTab = .series case .community: viewModel.selectedTab = .community case .fanTalk: viewModel.selectedTab = .fanTalk case .donation: viewModel.selectedTab = .donation default: viewModel.selectedTab = tab } } private func showContentDetail(_ contentId: Int) { guard contentId > 0 else { return } AppState.shared.setAppStep(step: .contentDetail(contentId: contentId)) } private func showLiveDetail(_ roomId: Int) { guard roomId > 0 else { return } AppState.shared.setAppStep( step: .liveDetail( roomId: roomId, onClickParticipant: {}, onClickReservation: {}, onClickStart: {}, onClickCancel: {} ) ) } private func showSeriesDetail(_ seriesId: Int) { guard seriesId > 0 else { return } AppState.shared.setAppStep(step: .seriesDetail(seriesId: seriesId)) } private func showDonationRankingAll() { AppState.shared.setAppStep(step: .userProfileDonationAll(userId: creatorId)) } private func showCommunity(_ creatorId: Int) { guard creatorId > 0 else { return } AppState.shared.setAppStep(step: .creatorCommunityAll(creatorId: creatorId)) } private func showCommunityWrite() { isCreatorActionMenuPresented = false AppState.shared.setAppStep( step: .creatorCommunityWrite(onSuccess: { viewModel.fetchHome(creatorId: creatorId) communityViewModel.fetchFirstPage(creatorId: creatorId) }) ) } private func showCommunityPostDetail(_ post: CreatorChannelCommunityPostItem) { AppState.shared.setAppStep( step: .creatorChannelCommunityPostDetail( postId: post.postId, onCommunityRefresh: { communityViewModel.fetchFirstPage(creatorId: creatorId) } ) ) } private func showAudioContentUpload() { isCreatorActionMenuPresented = false AppState.shared.setAppStep( step: .createContent(onSuccess: { viewModel.fetchHome(creatorId: creatorId) }) ) } private func showCreateLive() { isCreatorActionMenuPresented = false AppState.shared.setAppStep(step: .createLive(timeSettingMode: .NOW, onSuccess: handleCreateLiveSuccess)) } private func handleCreateLiveSuccess(response: CreateLiveRoomResponse) { liveViewModel.getLiveMain() if let _ = response.channelName { liveViewModel.enterRoom(roomId: response.id!) } viewModel.fetchHome(creatorId: creatorId) } private func handleScheduleTap(type: CreatorActivityType, targetId: Int) { guard targetId > 0 else { return } switch type { case .live, .liveReplay: showLiveDetail(targetId) case .audio: showContentDetail(targetId) case .community: showCommunity(targetId) case .unknown: return } } private func handleTalkTap() { guard let characterId = viewModel.response?.creator.characterId, characterId > 0 else { return } let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { AppState.shared.setAppStep(step: .login) return } let normalizedCountryCode = UserDefaults .string(forKey: .countryCode) .trimmingCharacters(in: .whitespacesAndNewlines) .uppercased() let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR" if isKoreanCountry && auth == false { pendingAction = { startChat(characterId: characterId) } isShowAuthConfirmView = true return } if !UserDefaults.isAdultContentVisible() { pendingAction = nil moveToContentSettingsWithGuideToast() return } startChat(characterId: characterId) } private func handleDmTap() { guard let creatorId = viewModel.response?.creator.creatorId, creatorId > 0 else { return } let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { AppState.shared.setAppStep(step: .login) return } AppState.shared.setAppStep(step: .userCreatorChatCreator(creatorId: creatorId)) } private func startChat(characterId: Int) { viewModel.createChatRoom(characterId: characterId) { chatRoomId in AppState.shared.setAppStep(step: .chatRoom(id: chatRoomId)) } } private func moveToContentSettingsWithGuideToast() { AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide) AppState.shared.setAppStep(step: .contentViewSettings) } private func dismissCommunityDeleteConfirm() { communityViewModel.isShowDeleteConfirm = false } private func showDonationDialog() { guard let creatorId = viewModel.response?.creator.creatorId else { return } channelDonationViewModel.setCreatorId(creatorId, shouldFetch: false) isShowChannelDonationDialog = true } @ViewBuilder private func communityOverlay(proxy: GeometryProxy) -> some View { if viewModel.selectedTab == .community { if communityViewModel.isShowReportMenu { VStack(spacing: 0) { CreatorCommunityMenuView( isShowing: $communityViewModel.isShowReportMenu, isShowCreatorMenu: isOwnCreatorChannel, isFixed: communityViewModel.selectedPostIsPinned, fixedAction: { communityViewModel.updateCommunityPostFixed(creatorId: creatorId) }, modifyAction: showCommunityModify, deleteAction: { if isOwnCreatorChannel { communityViewModel.isShowDeleteConfirm = true } }, reportAction: { communityViewModel.isShowReportView = true } ) if proxy.safeAreaInsets.bottom > 0 { Rectangle() .foregroundColor(Color(hex: "222222")) .frame(width: proxy.size.width, height: 15.3) } } .ignoresSafeArea() } if communityViewModel.isShowReportView { CreatorCommunityReportView(isShowing: $communityViewModel.isShowReportView) { reason in communityViewModel.report(reason: reason) } } if communityViewModel.isShowDeleteConfirm { SodaV2ActionModal( title: I18n.Common.postDeleteTitle, message: I18n.Common.confirmDeleteQuestion, button1: SodaV2ActionModalButton( label: I18n.Common.delete, action: { communityViewModel.deleteCommunityPost(creatorId: creatorId) communityViewModel.isShowDeleteConfirm = false } ), button2: SodaV2ActionModalButton( label: I18n.Common.cancel, action: dismissCommunityDeleteConfirm ), onDimmedTap: dismissCommunityDeleteConfirm ) } if communityViewModel.isShowPostPurchaseView { CommunityPostPurchaseDialog( isShowing: $communityViewModel.isShowPostPurchaseView, can: communityViewModel.selectedPostPrice, confirmAction: { communityViewModel.purchaseCommunityPost(creatorId: creatorId) } ) } if communityViewModel.isLoading { LoadingView() } } } private func showCommunityModify() { let postId = communityViewModel.selectedPostId guard postId > 0 else { return } AppState.shared.setAppStep( step: .creatorCommunityModify( postId: postId, onSuccess: { communityViewModel.fetchFirstPage(creatorId: creatorId) } ) ) communityViewModel.selectedPostId = 0 } private func showFanTalkWriteView() { AppState.shared.setAppStep( step: .creatorChannelFanTalkWrite( creatorId: creatorId, mode: .write, fanTalkId: nil, initialContent: "", onSuccess: { viewModel.fetchHome(creatorId: creatorId) fanTalkViewModel.fetchFirstPage(creatorId: creatorId) } ) ) } private func showFanTalkModifyView(_ fanTalk: CreatorChannelFanTalkItemResponse) { AppState.shared.setAppStep( step: .creatorChannelFanTalkWrite( creatorId: creatorId, mode: .modify(fanTalkId: fanTalk.fanTalkId, initialContent: fanTalk.content), fanTalkId: fanTalk.fanTalkId, initialContent: fanTalk.content, onSuccess: { viewModel.fetchHome(creatorId: creatorId) fanTalkViewModel.fetchFirstPage(creatorId: creatorId) } ) ) } private func showFanTalkReplyDetail(_ fanTalk: CreatorChannelFanTalkItemResponse) { guard isOwnCreatorChannel else { return } AppState.shared.setAppStep( step: .creatorChannelReplyDetail( context: .fanTalk(creatorId: creatorId, parentFanTalk: fanTalk), onFanTalkRefresh: { fanTalkViewModel.fetchFirstPage(creatorId: creatorId) } ) ) } } struct CreatorChannelView_Previews: PreviewProvider { static var previews: some View { CreatorChannelView(creatorId: 1, viewModel: sampleViewModel) } private static var sampleViewModel: CreatorChannelViewModel { let viewModel = CreatorChannelViewModel() viewModel.response = CreatorChannelHomeResponse( creator: CreatorChannelCreatorResponse( creatorId: 1, characterId: 10, nickname: "Soda Creator", profileImageUrl: "https://picsum.photos/600", followerCount: 12345, isAiChatAvailable: true, isDmAvailable: false, isFollow: true, isNotify: true ), currentLive: nil, latestAudioContent: CreatorChannelAudioContentResponse( audioContentId: 1, title: "최신 오디오 콘텐츠 제목", duration: "1:43:25", imageUrl: "https://picsum.photos/300/300", price: 0, isAdult: false, isPointAvailable: true, isFirstContent: true, seriesName: nil, isOriginalSeries: nil, isOwned: false, isRented: false ), channelDonations: [ CreatorChannelDonationResponse( nickname: "팬 이름 50", profileImageUrl: "https://picsum.photos/120/120?1", can: 50, message: "크리에이터가 커뮤니티에 올린 글이 보이는 부분 크리에이터가 커뮤니티에 올린 글이 보이는 부분", createdAtUtc: "2026-07-03T03:44:00Z" ), CreatorChannelDonationResponse( nickname: "팬 이름 100", profileImageUrl: "https://picsum.photos/120/120?2", can: 100, message: "51~100캔 후원 컬러를 확인하는 Preview 샘플입니다.", createdAtUtc: "2026-07-03T03:43:00Z" ), CreatorChannelDonationResponse( nickname: "팬 이름 500", profileImageUrl: "https://picsum.photos/120/120?3", can: 500, message: "101~500캔 후원 컬러를 확인하는 Preview 샘플입니다.", createdAtUtc: "2026-07-03T03:42:00Z" ), CreatorChannelDonationResponse( nickname: "팬 이름 501", profileImageUrl: "https://picsum.photos/120/120?4", can: 501, message: "501캔 이상 후원 컬러를 확인하는 Preview 샘플입니다.", createdAtUtc: "2026-07-03T03:41:00Z" ) ], notices: [], schedules: [], audioContents: [], series: [], communities: [], fanTalk: CreatorChannelFanTalkSummaryResponse(totalCount: 0, latestFanTalk: nil), introduce: "", activity: CreatorChannelActivityResponse( debutDateUtc: nil, dday: "D+1", liveCount: 0, liveDurationHours: 0, liveContributorCount: 0, audioContentCount: 0, seriesCount: 0 ), sns: CreatorChannelSnsResponse( instagramUrl: "", fancimmUrl: "", xurl: "", youtubeUrl: "", kakaoOpenChatUrl: "" ) ) viewModel.hasLoaded = true return viewModel } }