From 1d9964721f3743a1b233156c5aa88b67cb727073 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 7 Jan 2025 18:32:34 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=A4=EB=94=94=EC=85=98=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Contents.json | 21 ++ .../ic_mic_color_button.png | Bin 0 -> 1129 bytes .../ic_note_square.imageset/Contents.json | 21 ++ .../ic_note_square.png | Bin 0 -> 1167 bytes .../ic_upload.imageset/Contents.json | 21 ++ .../ic_upload.imageset/ic_upload.png | Bin 0 -> 861 bytes .../Applicant/ApplyAuditionRoleRequest.swift | 11 + .../Audition/Applicant/ApplyMethodView.swift | 108 +++++++++ .../AuditionApplicantRecordingView.swift | 206 ++++++++++++++++++ .../Applicant/AuditionApplyView.swift | 165 ++++++++++++++ SodaLive/Sources/Audition/AuditionApi.swift | 10 + .../Sources/Audition/AuditionRepository.swift | 4 + .../Role/AuditionRoleDetailView.swift | 102 +++++++++ .../Role/AuditionRoleDetailViewModel.swift | 110 ++++++++++ 14 files changed, 779 insertions(+) create mode 100644 SodaLive/Resources/Assets.xcassets/ic_mic_color_button.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_mic_color_button.imageset/ic_mic_color_button.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_note_square.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_note_square.imageset/ic_note_square.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_upload.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_upload.imageset/ic_upload.png create mode 100644 SodaLive/Sources/Audition/Applicant/ApplyAuditionRoleRequest.swift create mode 100644 SodaLive/Sources/Audition/Applicant/ApplyMethodView.swift create mode 100644 SodaLive/Sources/Audition/Applicant/AuditionApplicantRecordingView.swift create mode 100644 SodaLive/Sources/Audition/Applicant/AuditionApplyView.swift diff --git a/SodaLive/Resources/Assets.xcassets/ic_mic_color_button.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_mic_color_button.imageset/Contents.json new file mode 100644 index 0000000..4ccc242 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_mic_color_button.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_mic_color_button.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_mic_color_button.imageset/ic_mic_color_button.png b/SodaLive/Resources/Assets.xcassets/ic_mic_color_button.imageset/ic_mic_color_button.png new file mode 100644 index 0000000000000000000000000000000000000000..08c059cb45e3842e92bc881b9f27228ba0fdc264 GIT binary patch literal 1129 zcmV-v1eW`WP)Y=dQz@@UCX(U0s>N?SpQu? z5T}L&74nV{KZ^BV6-a@^mVAEF^e2RLXc5Yl^EbK6Bo8IFq!8j1(j$xPB9IV?Ncx+^ ze2JrwPrB@hNFq9^d@4K;Y$WS$h|*~@d)pRGu!QM9GG}^@8e6h+1502L*K_7>lj;bv zfhTu2lL;P-WD-nB+QNjSElfz-!i1zPOi0?oqaZn@{&kZor>b5{>FuOVm~24DMW{Hj zoAuGU`s4iNLgmo#0EpruRGb({39_Ax-Pq9-QCub)#}+0eZQ=2flvB<^s7-9N%DAj5Gv`e#`Cq@0m0bI?&_3ZB`LCn zOGAxvfTSQe3O>}Hg>BFQWtqQW>P@5+UtK#BDFxfe!K1s+*oiaDI506Qxm#LmvX77_ zcOWTve3EiAGQtv~8fbCPU(X-kll1-4lkWnZvQgGl%gz>gmSE{!0#k=@v;>9kf_1CaAfr%O5RpR=vf^D6V^~; zc9Y71a*bYyC;4ofxdt~*e^dmOydAV6g53SGnydBS4^G6^Y`4XQMsgmj#F2!cnu`V2 z<-so)QhHVR&f@!pW?XATN7aA^m$`?~-RuwS^?NgB!C?WY`afKO2GKhvqgoVsBI*RX-$2 z-`NV6;_gU7CgLHJL9v7a7qxZAL9hkS-sbirr+^rYaBv%N`G_mWRmft4UbUPvGjbWF z+yz_U+eeM3cCkqbFHvpLKX?I@uf9rUSw)U-HjX9tlhndfmy%!Bo9i5{uY>YAz{otE zfL!qCSRfpwE!2-)l<%Qf|EbQ_K3NWIr?07O(^vrH!Ao}8Rag4#3w3m{j+fD0V?*Vk7^ zwgY(_rEE6KbNlG%=vr@_BRL!nuRyE>f{o+jV@I|OH#avHqM z$cv6Nhd*?`GcgbB zco-?%eJjbY$gmyfqL&J%99J%vT?vVekc>%YWmp@@>2!L5OsbZI3_}14m&+)WrxFuu zAxQ^;iM3;pnc#ea*!_U|#8XM73F#s!N^*WIBeO1S%JH1Pyu3K;CbI||iId++kl1BB zreH_X6zoWvf*naykh=9G^Ik1aogtB!NJ6BBq)NiM>uu@Rag9Y^5+D^ML9hfeBUzBx zOM+zTDjEm@D3z_%EnUHTrNlZ zu_PgsiWj^(2ww;FgNN-%0z@WB73F4>SI4=FqmGGGP(5c|V&eJv*}ZUBg%G!!n>@x*CENZ^-0Ayj+%iOxByCZbB4FzT3D!aIGR*zd zSjVelT+{ERRlTWQa{Rx!3{fRH=jI{BX;3F_cc>t~6zF@2zLs}?oR7In&dn~|{|Cx5 z{de(@yAlzJ;v^k`OPJ^GZm#6)D0_`xT32X<)d?U|rKy z0mAc`39;m)9>?|1B(mn)I5U1@`?KfAGcka{U@#c;fj0?$GVmLOR-2F-qF@b{!w3*Z z7zD=`Eyqn;D3$2LfnT#M>kx<D5_b+yw9xRb*2jmK2j~LsJ?5h2_ z2YX`Miz~;}hvc1TY9hJ4T*)XxxQE1FNAnQaZsUy~TfJjNW%Zb=vfe65hS+z+(-Zwf zBy0WeUD$|=jTH@WE||cD6~t{iV;=C+Xx_DJpAsvIbG;i8{|YOLsCnK@TG2Bnw9y$n zCEmd@pWQY#g(nQlrhLkN^if9^1;`}4ckAWFvY4*)XiAvO)N!gYUDN#sz zLZTndKx9{(^JZRIi(qDIhRnK(i;EQD#<_y@tBtmxiXMf zR?cQb?dVRXkK$%tZm%83#ocJlD2XIVDO4Z$9b{sqB81@Ra_2!;{#4C<#fwF6xOKk| z1yD#ET_m}vd=XD}5xGV4N`{mzn@hAP$&fN-Gm9228ImOX=$*d@8OiZPXO~)_0Lc)Q zEJL;n8Obb)gkB_Bm}E$-ERqKqu^}S?1zpA`t8Ty%4vLfvk;fE6Pm|IA^W6R&zrvS> z%%W4V%xy;Zd9K0Frrn%h*k|FS~ZMl)i9z}!-$q}A}^XI;NL(a+C=4F n2a)fBJWg-`27|$1s3`vc<5aPjAFjaP00000NkvXXu0mjfh%j{0 literal 0 HcmV?d00001 diff --git a/SodaLive/Sources/Audition/Applicant/ApplyAuditionRoleRequest.swift b/SodaLive/Sources/Audition/Applicant/ApplyAuditionRoleRequest.swift new file mode 100644 index 0000000..c1ed03c --- /dev/null +++ b/SodaLive/Sources/Audition/Applicant/ApplyAuditionRoleRequest.swift @@ -0,0 +1,11 @@ +// +// ApplyAuditionRoleRequest.swift +// SodaLive +// +// Created by klaus on 1/7/25. +// + +struct ApplyAuditionRoleRequest: Encodable { + let roleId: Int + let phoneNumber: String +} diff --git a/SodaLive/Sources/Audition/Applicant/ApplyMethodView.swift b/SodaLive/Sources/Audition/Applicant/ApplyMethodView.swift new file mode 100644 index 0000000..207bef7 --- /dev/null +++ b/SodaLive/Sources/Audition/Applicant/ApplyMethodView.swift @@ -0,0 +1,108 @@ +// +// ApplyMethodView.swift +// SodaLive +// +// Created by klaus on 1/7/25. +// + +import SwiftUI + +struct ApplyMethodView: View { + + @Binding var isShowing: Bool + + let onClickSelectAudioFile: () -> Void + let onClickRecording: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.7).ignoresSafeArea() + .onTapGesture { + isShowing = false + } + + VStack(spacing: 0) { + HStack(spacing: 0) { + Spacer() + Image("ic_noti_stop") + .onTapGesture { + isShowing = false + } + } + + Text("오디션 지원방식") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color.graybb) + .padding(.top, 33.3) + + HStack(spacing: 13.3) { + HStack(spacing: 3) { + Image("ic_upload") + + Text("파일 업로드") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color.button) + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.bg) + .cornerRadius(5.3) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke() + .foregroundColor(Color.button) + ) + .contentShape(Rectangle()) + .onTapGesture { + onClickSelectAudioFile() + } + + HStack(spacing: 3) { + Image("ic_mic_color_button") + + Text("바로 녹음") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color.button) + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.bg) + .cornerRadius(5.3) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke() + .foregroundColor(Color.button) + ) + .contentShape(Rectangle()) + .onTapGesture { + onClickRecording() + } + } + .padding(.top, 21.3) + + HStack(spacing: 0) { + Text("※ 파일은 mp3, aac만 업로드 가능") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color.gray77) + .padding(.top, 13.3) + + Spacer() + } + } + .padding(.horizontal, 16.7) + .padding(.vertical, 13.3) + .frame(maxWidth: .infinity) + .background(Color.gray22) + .cornerRadius(10.3) + .padding(.horizontal, 13.3) + } + } +} + +#Preview { + ApplyMethodView( + isShowing: .constant(true), + onClickSelectAudioFile: {}, + onClickRecording: {} + ) +} diff --git a/SodaLive/Sources/Audition/Applicant/AuditionApplicantRecordingView.swift b/SodaLive/Sources/Audition/Applicant/AuditionApplicantRecordingView.swift new file mode 100644 index 0000000..c99fc2f --- /dev/null +++ b/SodaLive/Sources/Audition/Applicant/AuditionApplicantRecordingView.swift @@ -0,0 +1,206 @@ +// +// AuditionApplicantRecordingView.swift +// SodaLive +// +// Created by klaus on 1/7/25. +// + +import SwiftUI + +struct AuditionApplicantRecordingView: View { + @StateObject var soundManager = CreatorCommunitySoundManager() + + @Binding var isShowing: Bool + @Binding var isShowPopup: Bool + @Binding var errorMessage: String + + let onClickCompleteRecording: (String, Data) -> Void + + @State private var tempFileName = "" + + var body: some View { + ZStack { + Color.black.opacity(0.7) + .ignoresSafeArea() + + GeometryReader { proxy in + VStack { + Spacer() + + VStack { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("오디션 녹음") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + + Spacer() + + Image("ic_close_white") + .resizable() + .frame(width: 20, height: 20) + .onTapGesture { isShowing = false } + } + .padding(.horizontal, 26.7) + .padding(.top, 26.7) + } + + Text(soundManager.timeString) + .font(.custom(Font.bold.rawValue, size: 33.3)) + .foregroundColor(.white) + .padding(.top, 80) + + switch soundManager.recordMode { + case .RECORD: + if !soundManager.isLoading { + Image(soundManager.isRecording ? "ic_record_stop" : "ic_record") + .resizable() + .frame(width: 70, height: 70) + .padding(.vertical, 52.3) + .onTapGesture { + if !soundManager.isLoading { + if !soundManager.isRecording { + tempFileName = "voiceon_now_voice_\(Int(Date().timeIntervalSince1970 * 1000)).m4a" + soundManager.startRecording(tempFileName) + } else { + soundManager.stopRecording() + soundManager.recordMode = .PLAY + } + } + } + } + + case .PLAY: + if !soundManager.isLoading { + VStack(spacing: 0) { + HStack(spacing: 0) { + Spacer() + + Text("삭제") + .font(.custom(Font.medium.rawValue, size: 15.3)) + .foregroundColor(Color.graybb.opacity(0)) + + Spacer() + + Image( + !soundManager.isPlaying ? + "ic_record_play" : + "ic_record_pause" + ) + .onTapGesture { + if !soundManager.isLoading { + if !soundManager.isPlaying { + soundManager.playAudio() + } else { + soundManager.stopAudio() + } + } + } + + Spacer() + + Text("삭제") + .font(.custom(Font.medium.rawValue, size: 15.3)) + .foregroundColor(Color.graybb) + .onTapGesture { + soundManager.stopAudio() + soundManager.deleteAudioFile() + soundManager.recordMode = .RECORD + } + + Spacer() + } + .padding(.vertical, 52.3) + + HStack(spacing: 13.3) { + Text("다시 녹음") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color.button) + .frame(width: (proxy.size.width - 40) / 3, height: 50) + .background(Color.button.opacity(0.2)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.button, lineWidth: 1.3) + ) + .onTapGesture { + soundManager.stopAudio() + soundManager.deleteAudioFile() + soundManager.recordMode = .RECORD + } + + Text("녹음완료") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + .frame(width: (proxy.size.width - 40) * 2 / 3, height: 50) + .background(Color.button) + .cornerRadius(10) + .onTapGesture { + do { + let soundData = try Data(contentsOf: soundManager.getAudioFileURL()) + onClickCompleteRecording(tempFileName, soundData) + } catch { + errorMessage = "녹음파일을 생성하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + isShowPopup = true + } + } + } + .padding(.bottom, 40) + .padding(.horizontal, 13.3) + } + } + } + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color.gray22) + .frame(width: proxy.size.width, height: 15.3) + } + + if soundManager.isLoading { + LoadingView() + } + } + .background(Color(hex: "222222")) + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + } + .edgesIgnoringSafeArea(.bottom) + .onAppear { + soundManager.prepareRecording() + } + } + } + .popup(isPresented: $soundManager.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(soundManager.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: screenSize().width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color.button) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + .onDisappear { + if soundManager.onClose { + isShowing = false + } + } + } + } + } +} + +#Preview { + AuditionApplicantRecordingView( + isShowing: .constant(false), + isShowPopup: .constant(false), + errorMessage: .constant(""), + onClickCompleteRecording: { _, _ in } + ) +} diff --git a/SodaLive/Sources/Audition/Applicant/AuditionApplyView.swift b/SodaLive/Sources/Audition/Applicant/AuditionApplyView.swift new file mode 100644 index 0000000..201573d --- /dev/null +++ b/SodaLive/Sources/Audition/Applicant/AuditionApplyView.swift @@ -0,0 +1,165 @@ +// +// AuditionApplyView.swift +// SodaLive +// +// Created by klaus on 1/7/25. +// + +import SwiftUI + +struct AuditionApplyView: View { + + @Binding var isShowing: Bool + @Binding var phoneNumber: String + let filename: String + let onClickApply: () -> Void + + @State private var isShowPopup = false + @State private var errorMessage = "" + + @State private var isShow: Bool = false + @State private var isAgree = false + + var body: some View { + ZStack { + Color.black.opacity(0.7).ignoresSafeArea() + .onTapGesture { + isShowing = false + } + + VStack(spacing: 0) { + Spacer() + + if isShow { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + Text("오디션 지원") + .font(.custom(Font.medium.rawValue, size: 18.3)) + .foregroundColor(.white) + + Spacer() + + Image("ic_noti_stop") + .onTapGesture { + isShowing = false + } + } + + Text("녹음파일") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(.grayee) + .padding(.top, 20) + + HStack(spacing: 4) { + Image("ic_note_square") + + Text(filename) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.grayd2) + + Spacer() + } + .padding(.horizontal, 13.3) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color.bg) + .cornerRadius(5.3) + .padding(.top, 10) + + Text("연락처") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(.grayee) + .padding(.top, 15) + + TextField("합격시 받을 연락처를 남겨주세요", text: $phoneNumber) + .autocapitalization(.none) + .disableAutocorrection(true) + .keyboardType(.decimalPad) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.grayee) + .padding(.horizontal, 13.3) + .padding(.vertical, 17) + .frame(maxWidth: .infinity) + .background(Color.bg) + .cornerRadius(5.3) + .padding(.top, 10) + + HStack(alignment: .top, spacing: 13.3) { + Image(isAgree ? "btn_select_checked" : "btn_select_normal") + .resizable() + .frame(width: 20, height: 20) + + Text("보이스온 오디오 드라마 오디션 합격시 개인 연락을 위한 개인 정보(연락처) 수집 및 활용에 동의합니다.\n오디션 지원자는 개인정보 수집 및 활용 동의에 거부할 권리가 있으며 비동의시 오디션 지원은 취소 됩니다.") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color.grayee) + .lineSpacing(3) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 30) + .onTapGesture { + isAgree.toggle() + } + + Text("오디션 지원하기") + .font(.custom(Font.bold.rawValue, size: 13.3)) + .foregroundColor(Color.grayee) + .padding(.vertical, 13.3) + .frame(maxWidth: .infinity) + .background(Color.button) + .cornerRadius(8) + .padding(.top, 35) + .onTapGesture { + if !isAgree { + errorMessage = "연락처 수집 및 활용에 동의하셔야 오디션 지원이 가능합니다." + isShowPopup = true + return + } + + onClickApply() + } + } + .frame(maxWidth:.infinity) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 32) + .background(Color.gray22) + .cornerRadius(10, corners: [.topLeft, .topRight]) + .transition(.move(edge: .bottom)) + .animation(.easeInOut(duration: 0.5), value: isShow) + } + } + .ignoresSafeArea() + } + .popup(isPresented: $isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(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(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .onAppear { + withAnimation { + isShow = true + } + } + } +} + +#Preview { + AuditionApplyView( + isShowing: .constant(true), + phoneNumber: .constant(""), + filename: "now_voice_9292939.m4a", + onClickApply: {} + ) +} diff --git a/SodaLive/Sources/Audition/AuditionApi.swift b/SodaLive/Sources/Audition/AuditionApi.swift index f274fdd..3b93b6e 100644 --- a/SodaLive/Sources/Audition/AuditionApi.swift +++ b/SodaLive/Sources/Audition/AuditionApi.swift @@ -13,6 +13,7 @@ enum AuditionApi { case getAuditionDetail(auditionId: Int) case getAuditionRoleDetail(auditionRoleId: Int) case getAuditionApplicantList(auditionRoleId: Int, sortType: AuditionApplicantSortType, page: Int, size: Int) + case applyAudition(parameters: [MultipartFormData]) } extension AuditionApi: TargetType { @@ -33,6 +34,9 @@ extension AuditionApi: TargetType { case .getAuditionApplicantList: return "/audition/applicant" + + case .applyAudition: + return "/audition/applicant" } } @@ -41,6 +45,9 @@ extension AuditionApi: TargetType { case .getAuditionList, .getAuditionDetail, . getAuditionRoleDetail, .getAuditionApplicantList: return .get + + case .applyAudition: + return .post } } @@ -67,6 +74,9 @@ extension AuditionApi: TargetType { ] as [String : Any] return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + + case .applyAudition(let parameters): + return .uploadMultipart(parameters) } } diff --git a/SodaLive/Sources/Audition/AuditionRepository.swift b/SodaLive/Sources/Audition/AuditionRepository.swift index 8c76dc8..ab48843 100644 --- a/SodaLive/Sources/Audition/AuditionRepository.swift +++ b/SodaLive/Sources/Audition/AuditionRepository.swift @@ -35,4 +35,8 @@ final class AuditionRepository { ) ) } + + func applyAudition(parameters: [MultipartFormData]) -> AnyPublisher { + return api.requestPublisher(.applyAudition(parameters: parameters)) + } } diff --git a/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift b/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift index 1237c1e..2fc9368 100644 --- a/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift +++ b/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift @@ -13,6 +13,12 @@ struct AuditionRoleDetailView: View { let roleId: Int @StateObject var viewModel = AuditionRoleDetailViewModel() + @StateObject var keyboardHandler = KeyboardHandler() + + @State private var isShowApplyMethodView = false + @State private var isShowSelectAudioView = false + @State private var isShowRecordingView = false + @State private var isShowApplyView = false var body: some View { BaseView(isLoading: $viewModel.isLoading) { @@ -83,6 +89,14 @@ struct AuditionRoleDetailView: View { AuditionApplicantItemView(item: applicant) .padding(.bottom, $0 == viewModel.applicantList.count - 1 ? 33 : 0) + + if $0 == viewModel.applicantList.count - 1 { + Color.clear + .frame(height: 0) + .onAppear { + viewModel.getAuditionApplicantList() + } + } } } } @@ -119,8 +133,96 @@ struct AuditionRoleDetailView: View { .cornerRadius(44) .padding(.trailing, 19) .padding(.bottom, 19) + .onTapGesture { + isShowApplyMethodView = 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 + } + } + ) + .offset(y: 0 - (keyboardHandler.keyboardHeight / 10)) + .onDisappear { + viewModel.soundData = nil + viewModel.fileName = "" + viewModel.deleteAllRecordingFilesWithNamePrefix("voiceon_now_voice") + } + } + } + } + + 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 } } } diff --git a/SodaLive/Sources/Audition/Role/AuditionRoleDetailViewModel.swift b/SodaLive/Sources/Audition/Role/AuditionRoleDetailViewModel.swift index a6962c5..38d3e3d 100644 --- a/SodaLive/Sources/Audition/Role/AuditionRoleDetailViewModel.swift +++ b/SodaLive/Sources/Audition/Role/AuditionRoleDetailViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import Moya import Combine final class AuditionRoleDetailViewModel: ObservableObject { @@ -31,6 +32,10 @@ final class AuditionRoleDetailViewModel: ObservableObject { } } + @Published var fileName = "" + @Published var soundData: Data? = nil + @Published var phoneNumber = "" + var page = 1 var isLast = false private var pageSize = 10 @@ -169,4 +174,109 @@ final class AuditionRoleDetailViewModel: ObservableObject { .store(in: &subscription) } } + + func applyAudition(onSuccess: @escaping () -> Void) { + if phoneNumber.count != 11 { + errorMessage = "잘못된 연락처 입니다.\n다시 입력해 주세요." + isShowPopup = true + return + } + + guard let soundData = soundData else { + errorMessage = "잘못된 녹음 파일 입니다.\n다시 선택해 주세요." + isShowPopup = true + return + } + + isLoading = true + + let request = ApplyAuditionRoleRequest(roleId: auditionRoleId, phoneNumber: phoneNumber) + var multipartData = [MultipartFormData]() + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let jsonData = try? encoder.encode(request) + + if let jsonData = jsonData { + multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request")) + multipartData.append( + MultipartFormData( + provider: .data(soundData), + name: "contentFile", + fileName: fileName, + mimeType: "audio/*" + ) + ) + + repository.applyAudition(parameters: multipartData) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.errorMessage = "오디션 지원이 완료되었습니다." + self.isShowPopup = true + self.deleteAllRecordingFilesWithNamePrefix("voiceon_") + self.applicantList = [] + self.totalCount = 0 + + self.page = 1 + self.isLast = false + self.getAuditionRoleDetail() + + onSuccess() + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "오디션 지원을 완료하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "오디션 지원을 완료하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } else { + self.errorMessage = "오디션 지원을 완료하지 못했습니다.\n다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + self.isLoading = false + } + } + + func deleteAllRecordingFilesWithNamePrefix(_ prefix: String) { + let fileManager = FileManager.default + let documentsURL = getDocumentsDirectory() + + do { + let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil, options: []) + for fileURL in fileURLs { + if fileURL.lastPathComponent.hasPrefix(prefix) { + try fileManager.removeItem(at: fileURL) + DEBUG_LOG("녹음 파일 삭제 성공: \(fileURL)") + } + } + } catch { + DEBUG_LOG("녹음 파일 삭제 실패: \(error.localizedDescription)") + } + } + + private func getDocumentsDirectory() -> URL { + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + return paths[0] + } }