// // AuditionRoleDetailView.swift // SodaLive // // Created by klaus on 1/6/25. // import SwiftUI import Kingfisher struct AuditionRoleDetailView: View { let roleId: Int let auditionTitle: String @StateObject var viewModel = AuditionRoleDetailViewModel() @StateObject var keyboardHandler = KeyboardHandler() @StateObject var soundManager = AuditionSoundManager.shared @State private var isShowApplyMethodView = false @State private var isShowSelectAudioView = false @State private var isShowRecordingView = false @State private var isShowNoticeAuthView = false @State private var isShowApplyView = false @State private var isShowNoticeReapply = false @State private var isShowApplyCompleteView = false var body: some View { BaseView(isLoading: $viewModel.isLoading) { ZStack(alignment: .bottomTrailing) { VStack(spacing: 0) { DetailNavigationBar(title: viewModel.name) ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: 15) { if let roleDetail = viewModel.auditionRoleDetail { KFImage(URL(string: roleDetail.imageUrl)) .cancelOnDisappear(true) .downsampling(size: CGSize(width: 1000, height: 350)) .resizable() .aspectRatio(1000/350, contentMode: .fit) .frame(maxWidth: .infinity) .cornerRadius(6.7) .padding(.top, 3) HStack(spacing: 14) { if let url = URL(string: roleDetail.originalWorkUrl), UIApplication.shared.canOpenURL(url) { Text("원작 보러가기") .font(.custom(Font.bold.rawValue, size: 16)) .foregroundColor(Color.button) .padding(.vertical, 12) .frame(maxWidth: .infinity) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color.button, lineWidth: 1) ) .contentShape(Rectangle()) .onTapGesture { UIApplication.shared.open(url) } } if let url = URL(string: roleDetail.auditionScriptUrl), UIApplication.shared.canOpenURL(url) { Text("오디션 대본 확인") .font(.custom(Font.bold.rawValue, size: 16)) .foregroundColor(Color.button) .padding(.vertical, 12) .frame(maxWidth: .infinity) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color.button, lineWidth: 1) ) .contentShape(Rectangle()) .onTapGesture { UIApplication.shared.open(url) } } } VStack(alignment: .leading, spacing: 13.3) { Text("오디션 캐릭터 정보") .font(.custom(Font.bold.rawValue, size: 14.7)) .foregroundColor(Color.grayee) ExpandableTextView(text: roleDetail.information) } } if viewModel.applicantList.isEmpty { Text("지원자가 없습니다.") .font(.custom(Font.medium.rawValue, size: 13)) .foregroundColor(Color.grayee) .padding(.top, 15) } else { HStack(spacing: 0) { Text("참여자") .font(.custom(Font.medium.rawValue, size: 13.3)) .foregroundColor(Color.graybb) Text("\(viewModel.totalCount)") .font(.custom(Font.medium.rawValue, size: 13.3)) .foregroundColor(Color.button) .padding(.leading, 2.3) Text("명") .font(.custom(Font.medium.rawValue, size: 13.3)) .foregroundColor(Color.graybb) Spacer() Text("최신순") .font(.custom(Font.medium.rawValue, size: 13.3)) .foregroundColor( viewModel.sortType == .NEWEST ? Color.button : Color.graybb ) .onTapGesture { viewModel.setSortType(sortType: .NEWEST) } Text("좋아요순") .font(.custom(Font.medium.rawValue, size: 13.3)) .foregroundColor( viewModel.sortType == .LIKES ? Color.button : Color.graybb ) .onTapGesture { viewModel.setSortType(sortType: .LIKES) } .padding(.leading, 13.3) } .padding(.top, 15) VStack(spacing: 5.3) { ForEach(0..<viewModel.applicantList.count, id: \.self) { let applicant = viewModel.applicantList[$0] AuditionApplicantItemView( item: applicant, onClickVote: { viewModel.voteApplicant(applicantId: $0) } ).padding(.bottom, $0 == viewModel.applicantList.count - 1 ? 33 : 0) if $0 == viewModel.applicantList.count - 1 { Color.clear .frame(height: 0) .onAppear { viewModel.getAuditionApplicantList() } } } } } } .padding(.horizontal, 13.3) } } .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { HStack { Spacer() Text(viewModel.errorMessage) .padding(.vertical, 13.3) .frame(width: screenSize().width - 66.7, alignment: .center) .font(.custom(Font.medium.rawValue, size: 12)) .background(Color.button) .foregroundColor(Color.white) .multilineTextAlignment(.leading) .cornerRadius(20) .padding(.bottom, 66.7) Spacer() } } .popup(isPresented: $soundManager.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { HStack { Spacer() Text(soundManager.errorMessage) .padding(.vertical, 13.3) .frame(width: screenSize().width - 66.7, alignment: .center) .font(.custom(Font.medium.rawValue, size: 12)) .background(Color.button) .foregroundColor(Color.white) .multilineTextAlignment(.leading) .cornerRadius(20) .padding(.bottom, 66.7) Spacer() } } .onAppear { viewModel.onFailure = { AppState.shared.back() } viewModel.auditionRoleId = roleId } .onDisappear { soundManager.resetPlayer() } if let roleDetail = viewModel.auditionRoleDetail { Text(roleDetail.isAlreadyApplicant ? "오디션 재지원" : "오디션 지원") .font(.custom(Font.bold.rawValue, size: 15.3)) .foregroundColor(Color.white) .padding(14) .background(Color.button) .cornerRadius(44) .padding(.trailing, 19) .padding(.bottom, 19) .onTapGesture { if UserDefaults.bool(forKey: .auth) { if viewModel.isShowNoticeReapply { isShowNoticeReapply = true } else { isShowApplyMethodView = true } } else { isShowNoticeAuthView = true } } } } .fileImporter( isPresented: $isShowSelectAudioView, allowedContentTypes: [.audio], allowsMultipleSelection: false ) { result in handleFileImport(result: result) } if isShowApplyMethodView { ApplyMethodView( isShowing: $isShowApplyMethodView, onClickSelectAudioFile: { isShowApplyMethodView = false isShowSelectAudioView = true }, onClickRecording: { isShowApplyMethodView = false isShowRecordingView = true } ) } if isShowRecordingView { AuditionApplicantRecordingView( isShowing: $isShowRecordingView, isShowPopup: $viewModel.isShowPopup, errorMessage: $viewModel.errorMessage, onClickCompleteRecording: { fileName, soundData in viewModel.fileName = fileName viewModel.soundData = soundData isShowRecordingView = false isShowApplyView = true } ) } if isShowApplyView { AuditionApplyView( isShowing: $isShowApplyView, phoneNumber: $viewModel.phoneNumber, filename: viewModel.fileName, onClickApply: { viewModel.applyAudition { isShowApplyView = false isShowRecordingView = false isShowApplyCompleteView = true } } ) .offset(y: 0 - (keyboardHandler.keyboardHeight / 10)) .onDisappear { viewModel.soundData = nil viewModel.fileName = "" viewModel.deleteAllRecordingFilesWithNamePrefix("voiceon_now_voice") } } if isShowNoticeReapply { SodaDialog( title: "재지원 안내", desc: "재지원 시 이전 지원 내역은 삭제되며 받은 투표수는 무효 처리됩니다.", confirmButtonTitle: "확인" ) { isShowNoticeReapply = false isShowApplyMethodView = true } } if isShowNoticeAuthView { SodaDialog( title: "- 본인인증 -", desc: "마이페이지에서 '본인인증'을 하고 다시 오디션에 지원해 주세요.", confirmButtonTitle: "확인" ) { isShowNoticeAuthView = false } } if viewModel.isShowVoteCompleteView { SodaDialog( title: viewModel.dialogTitle, desc: viewModel.dialogDesc, confirmButtonTitle: "확인" ) { viewModel.isShowVoteCompleteView = false viewModel.isShowNotifyVote = false } } if isShowApplyCompleteView { ApplyAuditionCompleteDialog( auditionTitle: auditionTitle, roleName: viewModel.name, isShowing: $isShowApplyCompleteView ) } if soundManager.isLoading { LoadingView() } } } private func handleFileImport(result: Result<[URL], Error>) { switch result { case .success(let url): let fileUrl = url[0] if fileUrl.startAccessingSecurityScopedResource() { defer { fileUrl.stopAccessingSecurityScopedResource() } if let data = try? Data(contentsOf: fileUrl) { viewModel.soundData = data viewModel.fileName = fileUrl.lastPathComponent isShowApplyView = true } else { viewModel.errorMessage = "콘텐츠 파일을 불러오지 못했습니다.\n다시 선택해 주세요" viewModel.isShowPopup = true } } else { viewModel.errorMessage = "콘텐츠 파일을 불러오지 못했습니다.\n다시 선택해 주세요" viewModel.isShowPopup = true } case .failure(let error): DEBUG_LOG("error: \(error.localizedDescription)") viewModel.errorMessage = "콘텐츠 파일을 불러오지 못했습니다.\n다시 선택해 주세요" viewModel.isShowPopup = true } } } #Preview { AuditionRoleDetailView(roleId: 1, auditionTitle: "스위치온") }