// // MainView.swift // SodaLive // import SwiftUI import Bootpay import BootpayUI import Kingfisher struct MainView: View { @StateObject private var viewModel = MainViewModel() @StateObject private var legacyHomeViewModel = HomeViewModel() @StateObject private var liveViewModel = LiveViewModel() @StateObject private var mypageViewModel = MyPageViewModel() @StateObject private var appState = AppState.shared @StateObject private var contentPlayManager = ContentPlayManager.shared @StateObject private var contentPlayerPlayManager = ContentPlayerPlayManager.shared @AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token) @AppStorage("auth") private var auth: Bool = UserDefaults.bool(forKey: UserDefaultsKey.auth) @State private var isShowPlayer = false @State private var isShowAuthView = false @State private var isShowAuthConfirmView = false @State private var pendingAction: (() -> Void)? = nil @State private var isShowLeaveLiveNavigationDialog = false @State private var pendingExternalNavigationAction: (() -> Void)? = nil @State private var pendingExternalNavigationCancelAction: (() -> Void)? = nil @State private var payload = Payload() var body: some View { GeometryReader { proxy in ZStack(alignment: .bottom) { VStack(spacing: 0) { contentView .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.bottom, appState.isShowPlayer ? 72 : 0) if contentPlayerPlayManager.isShowingMiniPlayer { contentPlayerMiniPlayerView } if contentPlayManager.isShowingMiniPlayer { previewContentMiniPlayerView } } .safeAreaInset(edge: .bottom, spacing: 0) { MainTabBarView( width: proxy.size.width, currentTab: $viewModel.currentTab ) } .onAppear { configurePayload() if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { pushTokenUpdate() legacyHomeViewModel.getMemberInfo() legacyHomeViewModel.getEventPopup() legacyHomeViewModel.addAllPlaybackTracking() } } if appState.isShowNotificationSettingsDialog { NotificationSettingsDialog() } if isShowAuthConfirmView { authConfirmDialog } if liveViewModel.isShowPaymentDialog { LivePaymentDialog( title: liveViewModel.paymentDialogTitle, desc: liveViewModel.paymentDialogDesc, desc2: liveViewModel.paymentDialogDesc2, confirmButtonTitle: liveViewModel.paymentDialogConfirmTitle, confirmButtonAction: liveViewModel.paymentDialogConfirmAction, cancelButtonTitle: liveViewModel.paymentDialogCancelTitle, cancelButtonAction: liveViewModel.hidePopup, startDateTime: liveViewModel.liveStartDate, nowDateTime: liveViewModel.nowDate ) } if liveViewModel.isShowPasswordDialog { LiveRoomPasswordDialog( isShowing: $liveViewModel.isShowPasswordDialog, can: liveViewModel.secretOrPasswordDialogCan, confirmAction: liveViewModel.passwordDialogConfirmAction ) } if let eventItem = appState.eventPopup { VStack(spacing: 0) { Spacer() EventPopupDialogView(eventPopup: eventItem) } .background(Color.black) .onTapGesture { appState.eventPopup = nil } } if isShowPlayer { ContentPlayerView(isShowing: $isShowPlayer, playlist: []) } if appState.isShowPlayer { LiveRoomViewV2() } if isShowLeaveLiveNavigationDialog { leaveLiveNavigationDialog } } .fullScreenCover(isPresented: $isShowAuthView) { authView } .valueChanged(value: appState.pushRoomId) { handlePushRoomId($0) } .valueChanged(value: appState.pushChannelId) { handlePushChannelId($0) } .valueChanged(value: appState.pushMessageId) { handlePushMessageId($0) } .valueChanged(value: appState.pushAudioContentId) { handlePushAudioContentId($0) } .valueChanged(value: appState.pushSeriesId) { handlePushSeriesId($0) } .valueChanged(value: appState.pendingMainChatFilter) { handlePendingMainChatFilter($0) } .valueChanged(value: appState.isShowPlayer) { isShowPlayer in guard !isShowPlayer, let pendingExternalNavigationAction = pendingExternalNavigationAction else { return } self.pendingExternalNavigationAction = nil self.pendingExternalNavigationCancelAction = nil DispatchQueue.main.async { pendingExternalNavigationAction() } } .onAppear { if appState.pushMessageId > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { appState.setAppStep(step: .message) } } } .sodaToast( isPresented: $liveViewModel.isShowPopup, message: liveViewModel.errorMessage, autohideIn: 2 ) } } @ViewBuilder private var contentView: some View { switch viewModel.currentTab { case .home: MainHomeView( onTapCanCharge: handleHomeCanChargeTap, onTapSearch: handleHomeSearchTap, onTapNotificationList: handleHomeNotificationListTap, onTapLive: handleRecommendationLiveTap, onTapCreator: handleRecommendationCreatorTap, onTapContent: handleRecommendationContentTap, onTapCharacter: handleRecommendationCreatorTap, onTapCommunityPost: handleRecommendationCommunityPostTap, onTapBanner: handleRecommendationBannerTap, onTapFollowAll: handleRecommendationFollowAllTap, onSelectFollowingTab: handleFollowingTabSelection, onTapFollowingLogin: handleFollowingLoginTap, onTapFollowingAll: handleFollowingAllTap, onTapChatTab: handleFollowingChatTabTap, onTapChatRoom: handleFollowingChatRoomTap, onTapFollowingSchedule: handleFollowingScheduleTap ) case .content: MainContentView( onTapCanCharge: handleHomeCanChargeTap, onTapSearch: handleHomeSearchTap, onTapStorage: handleContentStorageTap, onTapContent: handleRecommendationContentTap, onTapBanner: handleContentAudioBannerTap, onTapSeries: handleContentOriginalSeriesTap, onTapNewAndHotAll: handleContentNewAndHotAllTap ) case .chat: MainChatView( onTapCanCharge: handleHomeCanChargeTap, onTapSearch: handleHomeSearchTap ) case .my: MyPageView() } } private var contentPlayerMiniPlayerView: some View { HStack(spacing: 0) { KFImage(URL(string: contentPlayerPlayManager.coverImageUrl)) .cancelOnDisappear(true) .downsampling(size: CGSize(width: 36.7, height: 36.7)) .resizable() .frame(width: 36.7, height: 36.7) .cornerRadius(5.3) VStack(alignment: .leading, spacing: 2.3) { Text(contentPlayerPlayManager.title) .appFont(size: 13, weight: .medium) .foregroundColor(Color.grayee) .lineLimit(2) Text(contentPlayerPlayManager.nickname) .appFont(size: 11, weight: .medium) .foregroundColor(Color.grayd2) } .padding(.horizontal, 10.7) Spacer() Image(contentPlayerPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play") .resizable() .frame(width: 25, height: 25) .onTapGesture { contentPlayerPlayManager.playOrPause() } Image("ic_noti_stop") .resizable() .frame(width: 25, height: 25) .padding(.leading, 16) .onTapGesture { contentPlayerPlayManager.resetPlayer() } } .padding(.vertical, 10.7) .padding(.horizontal, 13.3) .background(Color.gray22) .contentShape(Rectangle()) .onTapGesture { isShowPlayer = true } } private var previewContentMiniPlayerView: some View { HStack(spacing: 0) { KFImage(URL(string: contentPlayManager.coverImage)) .cancelOnDisappear(true) .downsampling(size: CGSize(width: 36.7, height: 36.7)) .resizable() .frame(width: 36.7, height: 36.7) .cornerRadius(5.3) VStack(alignment: .leading, spacing: 2.3) { Text(contentPlayManager.title) .appFont(size: 13, weight: .medium) .foregroundColor(Color.grayee) .lineLimit(2) Text(contentPlayManager.nickname) .appFont(size: 11, weight: .medium) .foregroundColor(Color.grayd2) } .padding(.horizontal, 10.7) Spacer() Image(contentPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play") .resizable() .frame(width: 25, height: 25) .onTapGesture { if contentPlayManager.isPlaying { contentPlayManager.pauseAudio() } else { contentPlayManager.playAudio(contentId: contentPlayManager.contentId) } } Image("ic_noti_stop") .resizable() .frame(width: 25, height: 25) .padding(.leading, 16) .onTapGesture { contentPlayManager.stopAudio() } } .padding(.vertical, 10.7) .padding(.horizontal, 13.3) .background(Color.gray22) .contentShape(Rectangle()) .onTapGesture { appState.setAppStep(step: .contentDetail(contentId: contentPlayManager.contentId)) } } private var authConfirmDialog: some View { SodaV2ActionModal( title: I18n.Main.Auth.dialogTitle, message: I18n.Main.Auth.liveEntryVerificationDescription, button1: SodaV2ActionModalButton( label: I18n.Main.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 leaveLiveNavigationDialog: some View { SodaV2ActionModal( title: I18n.Common.alertTitle, message: I18n.LiveRoom.leaveLiveForNavigationDesc, button1: SodaV2ActionModalButton( label: I18n.Common.confirm, action: confirmExternalNavigation ), button2: SodaV2ActionModalButton( label: I18n.Common.cancel, action: cancelExternalNavigation ), onDimmedTap: cancelExternalNavigation ) } private var authView: some View { BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) .onConfirm { _ in true } .onCancel { _ in isShowAuthView = false } .onError { _ in appState.errorMessage = I18n.Main.Auth.authenticationError appState.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 handlePushRoomId(_ value: Int) { guard value > 0 else { return } let roomId = value let isPushRoomFromDeepLink = appState.isPushRoomFromDeepLink appState.pushRoomId = 0 appState.isPushRoomFromDeepLink = false DispatchQueue.main.async { handleExternalNavigationRequest( value: roomId, navigationAction: { if !isPushRoomFromDeepLink { appState.setAppStep(step: .main) } liveViewModel.enterLiveRoom(roomId: roomId) }, cancelAction: { appState.pushRoomId = 0 appState.isPushRoomFromDeepLink = false } ) } } private func handlePushChannelId(_ value: Int) { guard value > 0 else { return } let channelId = value appState.pushChannelId = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { handleExternalNavigationRequest( value: channelId, navigationAction: { appState.setAppStep(step: .main) appState.setAppStep(step: .creatorDetail(userId: channelId)) }, cancelAction: { appState.pushChannelId = 0 } ) } } private func handlePushMessageId(_ value: Int) { guard value > 0 else { return } let messageId = value appState.pushMessageId = 0 DispatchQueue.main.async { handleExternalNavigationRequest( value: messageId, navigationAction: { appState.setAppStep(step: .main) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { appState.setAppStep(step: .message) } }, cancelAction: { appState.pushMessageId = 0 } ) } } private func handlePendingMainChatFilter(_ filter: MainChatFilter?) { guard filter != nil else { return } viewModel.currentTab = .chat } private func handlePushAudioContentId(_ value: Int) { guard value > 0 else { return } let contentId = value appState.pushAudioContentId = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { handleExternalNavigationRequest( value: contentId, navigationAction: { appState.setAppStep(step: .main) appState.setAppStep(step: .contentDetail(contentId: contentId)) }, cancelAction: { appState.pushAudioContentId = 0 } ) } } private func handlePushSeriesId(_ value: Int) { guard value > 0 else { return } let seriesId = value appState.pushSeriesId = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { handleExternalNavigationRequest( value: seriesId, navigationAction: { appState.setAppStep(step: .main) appState.setAppStep(step: .seriesDetail(seriesId: seriesId)) }, cancelAction: { appState.pushSeriesId = 0 } ) } } private func handleExternalNavigationRequest( value: Int, navigationAction: @escaping () -> Void, cancelAction: @escaping () -> Void ) { guard value > 0 else { return } if appState.isShowPlayer { pendingExternalNavigationAction = navigationAction pendingExternalNavigationCancelAction = cancelAction isShowLeaveLiveNavigationDialog = true return } navigationAction() } private func handleHomeCanChargeTap() { appState.setAppStep(step: .canCharge(refresh: {})) } private func handleHomeSearchTap() { appState.setAppStep(step: .search) } private func handleHomeNotificationListTap() { appState.setAppStep(step: .notificationList) } private func handleRecommendationLiveTap(roomId: Int) { guard roomId > 0 else { return } performRecommendationDetailAction { handleExternalNavigationRequest( value: roomId, navigationAction: { liveViewModel.enterLiveRoom(roomId: roomId) }, cancelAction: {} ) } } private func handleRecommendationCreatorTap(creatorId: Int) { guard creatorId > 0 else { return } performRecommendationDetailAction { appState.setAppStep(step: .creatorDetail(userId: creatorId)) } } private func handleRecommendationContentTap(contentId: Int) { guard contentId > 0 else { return } performRecommendationDetailAction { appState.setAppStep(step: .contentDetail(contentId: contentId)) } } private func handleRecommendationCommunityPostTap(postId: Int) { guard postId > 0 else { return } performRecommendationDetailAction { appState.setAppStep(step: .creatorChannelCommunityPostDetail(postId: postId)) } } private func handleRecommendationFollowAllTap(action: @escaping () -> Void) { performRecommendationDetailAction(action: action) } private func handleFollowingAllTap() { appState.setAppStep(step: .followingList) } private func handleFollowingChatTabTap() { viewModel.currentTab = .chat } private func handleFollowingChatRoomTap(roomId: Int, chatType: String) { guard roomId > 0 else { return } if chatType == "DM" { appState.setAppStep(step: .userCreatorChatRoom(roomId: roomId)) } else { appState.setAppStep(step: .chatRoom(id: roomId)) } } private func handleFollowingScheduleTap(type: CreatorActivityType, targetId: Int) { guard targetId > 0 else { return } switch type { case .live, .liveReplay: handleRecommendationLiveTap(roomId: targetId) case .audio: handleRecommendationContentTap(contentId: targetId) case .community: performRecommendationDetailAction { appState.setAppStep(step: .creatorCommunityAll(creatorId: targetId)) } case .unknown: return } } private func handleFollowingTabSelection() -> Bool { let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { AppState.shared.setAppStep(step: .login) return false } return true } private func handleFollowingLoginTap() { AppState.shared.setAppStep(step: .login) } private func handleRecommendationBannerTap(_ item: RecommendationBannerResponse) { if let eventItem = item.eventItem { performRecommendationDetailAction { appState.setAppStep(step: .eventDetail(event: eventItem)) } return } if let creatorId = item.creatorId { handleRecommendationCreatorTap(creatorId: creatorId) return } if let seriesId = item.seriesId { performRecommendationDetailAction { appState.setAppStep(step: .seriesDetail(seriesId: seriesId)) } return } openRecommendationBannerLink(item.link) } private func handleContentAudioBannerTap(_ item: AudioBannerResponse) { if let eventItem = item.eventItem { performRecommendationDetailAction { appState.setAppStep(step: .eventDetail(event: eventItem)) } return } if let creatorId = item.creatorId { handleRecommendationCreatorTap(creatorId: creatorId) return } if let seriesId = item.seriesId { handleContentOriginalSeriesTap(seriesId: seriesId) return } openRecommendationBannerLink(item.link) } private func handleContentNewAndHotAllTap() { performRecommendationDetailAction { appState.setAppStep(step: .contentOverview(type: .newAndHotAudio)) } } private func handleContentStorageTap() { AppState.shared.setAppStep(step: .myBox(currentTab: .orderlist)) } private func handleContentOriginalSeriesTap(seriesId: Int) { guard seriesId > 0 else { return } performRecommendationDetailAction { appState.setAppStep(step: .seriesDetail(seriesId: seriesId)) } } private func openRecommendationBannerLink(_ link: String?) { guard let link = link?.trimmingCharacters(in: .whitespacesAndNewlines), !link.isEmpty else { return } if AppDeepLinkHandler.handle(urlString: link) { return } guard let url = URL(string: link), UIApplication.shared.canOpenURL(url) else { return } UIApplication.shared.open(url) } private func performRecommendationDetailAction( requiresAdultContent: Bool = false, action: @escaping () -> Void ) { let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { AppState.shared.setAppStep(step: .login) return } if requiresAdultContent { let normalizedCountryCode = UserDefaults .string(forKey: .countryCode) .trimmingCharacters(in: .whitespacesAndNewlines) .uppercased() let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR" if isKoreanCountry && auth == false { pendingAction = action isShowAuthConfirmView = true return } if !UserDefaults.isAdultContentVisible() { pendingAction = nil moveToContentSettingsWithGuideToast() return } } action() } private func openLiveDetail(roomId: Int) { appState.setAppStep( step: .liveDetail( roomId: roomId, onClickParticipant: { appState.isShowPlayer = false liveViewModel.enterLiveRoom(roomId: roomId) }, onClickReservation: {}, onClickStart: {}, onClickCancel: {} ) ) } private func moveToContentSettingsWithGuideToast() { AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide) AppState.shared.setAppStep(step: .contentViewSettings) } private func confirmExternalNavigation() { guard pendingExternalNavigationAction != nil else { isShowLeaveLiveNavigationDialog = false return } isShowLeaveLiveNavigationDialog = false NotificationCenter.default.post(name: .requestLiveRoomQuitForExternalNavigation, object: nil) } private func cancelExternalNavigation() { isShowLeaveLiveNavigationDialog = false pendingExternalNavigationAction = nil pendingExternalNavigationCancelAction?() pendingExternalNavigationCancelAction = nil } private func pushTokenUpdate() { let pushToken = UserDefaults.string(forKey: .pushToken) if !pushToken.trimmingCharacters(in: .whitespaces).isEmpty { legacyHomeViewModel.pushTokenUpdate(pushToken: pushToken) } } } struct MainView_Previews: PreviewProvider { static var previews: some View { MainView() } }