From 36028aa10845d96ee5a400e369a1bf77aa520615 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 7 Jan 2025 01:10:20 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=A4=EB=94=94=EC=85=98=20=EB=B0=B0?= =?UTF-8?q?=EC=97=AD=20=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ic_audition_pause.imageset/Contents.json | 21 +++ .../ic_audition_pause.png | Bin 0 -> 293 bytes .../ic_audition_play.imageset/Contents.json | 21 +++ .../ic_audition_play.png | Bin 0 -> 1413 bytes .../ic_heart_vote.imageset/Contents.json | 21 +++ .../ic_heart_vote.imageset/ic_heart_vote.png | Bin 0 -> 860 bytes SodaLive/Sources/App/AppStep.swift | 2 + .../Applicant/AuditionApplicantItemView.swift | 72 ++++++++ .../Applicant/AuditionApplicantSortType.swift | 10 + .../GetAuditionApplicantListResponse.swift | 20 ++ SodaLive/Sources/Audition/AuditionApi.swift | 23 ++- .../Sources/Audition/AuditionRepository.swift | 15 ++ .../Audition/Detail/AuditionDetailView.swift | 6 + .../Detail/AuditionDetailViewModel.swift | 2 +- .../Role/AuditionRoleDetailView.swift | 130 +++++++++++++ .../Role/AuditionRoleDetailViewModel.swift | 172 ++++++++++++++++++ .../Role/GetAuditionRoleDetailResponse.swift | 16 ++ SodaLive/Sources/ContentView.swift | 3 + .../CustomView/ExpandableTextView.swift | 2 +- 19 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 SodaLive/Resources/Assets.xcassets/ic_audition_pause.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_audition_pause.imageset/ic_audition_pause.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_audition_play.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_audition_play.imageset/ic_audition_play.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_heart_vote.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_heart_vote.imageset/ic_heart_vote.png create mode 100644 SodaLive/Sources/Audition/Applicant/AuditionApplicantItemView.swift create mode 100644 SodaLive/Sources/Audition/Applicant/AuditionApplicantSortType.swift create mode 100644 SodaLive/Sources/Audition/Applicant/GetAuditionApplicantListResponse.swift create mode 100644 SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift create mode 100644 SodaLive/Sources/Audition/Role/AuditionRoleDetailViewModel.swift create mode 100644 SodaLive/Sources/Audition/Role/GetAuditionRoleDetailResponse.swift diff --git a/SodaLive/Resources/Assets.xcassets/ic_audition_pause.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_audition_pause.imageset/Contents.json new file mode 100644 index 0000000..98e823e --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_audition_pause.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_audition_pause.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_audition_pause.imageset/ic_audition_pause.png b/SodaLive/Resources/Assets.xcassets/ic_audition_pause.imageset/ic_audition_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..7c0758c34901c35d0dcb2a2cd77ae9d38189a07f GIT binary patch literal 293 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKw1|+Ti+$;i8oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{w_}!$B+ufx3?Vm4k-w@J-jF!qL-5&VB5ql z^MZH6JE_NuxKzHJ|1ESWMNByE`V2;fmZekP86MjrTOPk=$YVeT8Onyw12=}RcZ zCloHdM{W-9=KWe0&;R@}s*Y6N`~CmXyYjoA@7-*At=gU;K_&k;Z$pRMHiIspFBm*s L{an^LB{Ts5eH?H_ literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_audition_play.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_audition_play.imageset/Contents.json new file mode 100644 index 0000000..700b52d --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_audition_play.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_audition_play.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_audition_play.imageset/ic_audition_play.png b/SodaLive/Resources/Assets.xcassets/ic_audition_play.imageset/ic_audition_play.png new file mode 100644 index 0000000000000000000000000000000000000000..542d9472a0a25c2c695e15ee95faf9879418203a GIT binary patch literal 1413 zcmV;01$z34P);m#3OrmDa#YA3@-|=*u*KFf284)W7C?*24#R4zmneUBWAzNCKv==&AjWJg zx{SnE9rZh2d2li?I?NSDyUb&VynC1(SDq7;B&ahECDmrG>5= zpjiydk891-N416qiWui%Wp{W;%+;~W<1X<7Z$qKiTdN&hyi#&4zJkfS8Bma@L|Y^T z++oXxZvc#jwxdpld%Mxa)420^kcV@i2q88&q}h;nN9s@;-~ZVyP*I5^I!Le*?{eH> z>I`8V8NYqu;I1<`_lLiGyg7}mMxq~q1VMuDfFj&s3Xbq~8yhpu|7tV%r-QsnKHQ#? z=psbKQ?NaL?}7pt`j5>~F9(FQUHh>X!7#9N&KA670<-rVVPEj#O4?#_bg<0 zodTHCgLHyvADdSZfG+WyYrcJQ(f`rj`#9*r`~>epJnm7)mqWij<9{w1ZOvq>1rn7f z0h%>tsu~Xe58he{>X#?7sz={f_=R)^U>Hd4ETeLquB;khTlgx(4 z^6jl8Y^{#n@0{C7*AqX%8pv}Lw&EAN8^>M1EQzUFeo4)7GrrzQg=tnJ^PQu^?3SXS zlXvwfC-A-RgAX_Ebr~GFybNwHzrB%!?Dk67W*_=_S+$3sgCvhXPM5kS9(g4*F8=uivnztvLk{+85bYFvN+<#MFwLE{kbUf9%(syqoP!K>Vgch(3}`!t z^6!{~sAGuXl<>JIo_s=iDu618rye{3Y9JC?9za>>I?+KqbkN6D^b^c4ssW3E4k5*I zJd+lkY=DA|z!cCSf>=(_syzHkWj64oV>W6=PL64CUO6G5SvwLV;rm1k#pGRw`Qvch zqgZJsG=0cYM=^WEV%*MpUdAz>-m*cDf5Q^^kQZk^J?UfzD!=3l^st>2p22Jwc(n@C z1jQG4)1-ppTh7@*7jTS|hi?z_Fb|;E#?^5$aG`v6Hi@NN>9+_1TiN_ZV+ZlXR>Qn3A>OR&>JKtXqR1( zyuK$e2n_y}99#I?APXbC56Lz>381E?rlvw5WQ0Do_FxD1x%ifk-prc&!{y(w^KXp( z(O<{`b-gA`UU`7j1cJRq`|toe#O)97Ap@y|snhb{2|vOM_^=3KBV!;shh4Z!8r)R! z^>;3LeCS1=4EG%V2zH-(Y2*_LQt~qr(fn2D@Md^k=>JU<1TH{TSwTM<(L=kF(FGR4fa^vV)<&>D>e?4^@(mmKBn%EmcA~URFrF zdRT7hWU@lq&-@x_uVu?6N{G zVby)1()nbC+`=l1LZtJ{3PywlyV7Q41!Ka39cgp2f>B|?th8BK!MLzsM%uirU`1HS zCv8VouqG^IleQ--SQQr1N!yhbtP2atr0vTJg$N7L(#6OMg$WDmrHhjl3KbTVv}`3p mkd5EHxZSTcH8nLg+r)p`{5Al0>>Dfq0000 9 ? "\(item.nickname.prefix(9))..." : item.nickname) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color.white) + + Spacer() + } + } + + VStack(spacing: 2.3) { + Image("ic_heart_vote") + + Text("\(item.voteCount)") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color.gray77) + } + } + .padding(.vertical, 18.7) + + Color.gray55 + .frame(maxWidth: .infinity) + .frame(height: 1) + } + .padding(.horizontal, 13.3) + } +} + +#Preview { + AuditionApplicantItemView( + item: GetAuditionRoleApplicantItem( + applicantId: 1, + memberId: 2, + nickname: "유저일유저일유저일유저일", + profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", + voiceUrl: "", + voteCount: 777 + ) + ) +} diff --git a/SodaLive/Sources/Audition/Applicant/AuditionApplicantSortType.swift b/SodaLive/Sources/Audition/Applicant/AuditionApplicantSortType.swift new file mode 100644 index 0000000..e90c78e --- /dev/null +++ b/SodaLive/Sources/Audition/Applicant/AuditionApplicantSortType.swift @@ -0,0 +1,10 @@ +// +// AuditionApplicantSortType.swift +// SodaLive +// +// Created by klaus on 1/6/25. +// + +enum AuditionApplicantSortType: String, Codable { + case NEWEST, LIKE +} diff --git a/SodaLive/Sources/Audition/Applicant/GetAuditionApplicantListResponse.swift b/SodaLive/Sources/Audition/Applicant/GetAuditionApplicantListResponse.swift new file mode 100644 index 0000000..ef09461 --- /dev/null +++ b/SodaLive/Sources/Audition/Applicant/GetAuditionApplicantListResponse.swift @@ -0,0 +1,20 @@ +// +// GetAuditionApplicantListResponse.swift +// SodaLive +// +// Created by klaus on 1/6/25. +// + +struct GetAuditionApplicantListResponse: Decodable { + let totalCount: Int + let items: [GetAuditionRoleApplicantItem] +} + +struct GetAuditionRoleApplicantItem: Decodable { + let applicantId: Int + let memberId: Int + let nickname: String + let profileImageUrl: String + let voiceUrl: String + let voteCount: Int +} diff --git a/SodaLive/Sources/Audition/AuditionApi.swift b/SodaLive/Sources/Audition/AuditionApi.swift index 8b2918a..f274fdd 100644 --- a/SodaLive/Sources/Audition/AuditionApi.swift +++ b/SodaLive/Sources/Audition/AuditionApi.swift @@ -11,6 +11,8 @@ import Moya enum AuditionApi { case getAuditionList(page: Int, size: Int) case getAuditionDetail(auditionId: Int) + case getAuditionRoleDetail(auditionRoleId: Int) + case getAuditionApplicantList(auditionRoleId: Int, sortType: AuditionApplicantSortType, page: Int, size: Int) } extension AuditionApi: TargetType { @@ -20,19 +22,24 @@ extension AuditionApi: TargetType { var path: String { switch self { - case .getAuditionList: return "/audition" case .getAuditionDetail(let auditionId): return "/audition/\(auditionId)" + + case .getAuditionRoleDetail(let auditionRoleId): + return "/audition/role/\(auditionRoleId)" + + case .getAuditionApplicantList: + return "/audition/applicant" } } var method: Moya.Method { switch self { - case .getAuditionList, .getAuditionDetail: + case .getAuditionList, .getAuditionDetail, . getAuditionRoleDetail, .getAuditionApplicantList: return .get } } @@ -48,8 +55,18 @@ extension AuditionApi: TargetType { return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) - case .getAuditionDetail: + case .getAuditionDetail, .getAuditionRoleDetail: return .requestPlain + + case .getAuditionApplicantList(let auditionRoleId, let sortType, let page, let size): + let parameters = [ + "auditionRoleId": auditionRoleId, + "page": page - 1, + "size": size, + "sortType": sortType + ] as [String : Any] + + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) } } diff --git a/SodaLive/Sources/Audition/AuditionRepository.swift b/SodaLive/Sources/Audition/AuditionRepository.swift index 9447a2e..8c76dc8 100644 --- a/SodaLive/Sources/Audition/AuditionRepository.swift +++ b/SodaLive/Sources/Audition/AuditionRepository.swift @@ -20,4 +20,19 @@ final class AuditionRepository { func getAuditionDetail(auditionId: Int) -> AnyPublisher { return api.requestPublisher(.getAuditionDetail(auditionId: auditionId)) } + + func getAuditionRoleDetail(auditionRoleId: Int) -> AnyPublisher { + return api.requestPublisher(.getAuditionRoleDetail(auditionRoleId: auditionRoleId)) + } + + func getAuditionApplicantList(auditionRoleId: Int, sortType: AuditionApplicantSortType, page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher( + .getAuditionApplicantList( + auditionRoleId: auditionRoleId, + sortType: sortType, + page: page, + size: size + ) + ) + } } diff --git a/SodaLive/Sources/Audition/Detail/AuditionDetailView.swift b/SodaLive/Sources/Audition/Detail/AuditionDetailView.swift index 6fb365f..8908fac 100644 --- a/SodaLive/Sources/Audition/Detail/AuditionDetailView.swift +++ b/SodaLive/Sources/Audition/Detail/AuditionDetailView.swift @@ -45,7 +45,13 @@ struct AuditionDetailView: View { LazyVStack(spacing: 15) { ForEach(0.. Void) { + func getAuditionDetail(auditionId: Int, onFailure: @escaping () -> Void) { isLoading = true repository.getAuditionDetail(auditionId: auditionId) diff --git a/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift b/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift new file mode 100644 index 0000000..1237c1e --- /dev/null +++ b/SodaLive/Sources/Audition/Role/AuditionRoleDetailView.swift @@ -0,0 +1,130 @@ +// +// AuditionRoleDetailView.swift +// SodaLive +// +// Created by klaus on 1/6/25. +// + +import SwiftUI +import Kingfisher + +struct AuditionRoleDetailView: View { + + let roleId: Int + + @StateObject var viewModel = AuditionRoleDetailViewModel() + + 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 { + VStack(spacing: 5.3) { + ForEach(0..() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var totalCount = 0 + @Published var applicantList = [GetAuditionRoleApplicantItem]() + + @Published var name = "보이스온" + @Published var auditionRoleDetail: GetAuditionRoleDetailResponse? = nil + + @Published var sortType = AuditionApplicantSortType.NEWEST { + didSet { + page = 1 + isLast = false + getAuditionRoleDetail() + } + } + + var page = 1 + var isLast = false + private var pageSize = 10 + + var auditionRoleId = -1 { + didSet { + if auditionRoleId > 0 { + getAuditionRoleDetail() + } else { + onFailure() + } + } + } + + var onFailure: () -> Void = {} + + func getAuditionRoleDetail() { + isLoading = true + + let auditionRoleDetail = repository.getAuditionRoleDetail(auditionRoleId: auditionRoleId) + let auditionApplicantList = repository.getAuditionApplicantList(auditionRoleId: auditionRoleId, sortType: sortType, page: page, size: pageSize) + + Publishers + .CombineLatest(auditionRoleDetail, auditionApplicantList) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] (roleDetailResponse, applicantListResponse) in + let roleDetail = roleDetailResponse.data + let applicantList = applicantListResponse.data + + let jsonDecoder = JSONDecoder() + + do { + let roleDetailDecoded = try jsonDecoder.decode(ApiResponse.self, from: roleDetail) + if let data = roleDetailDecoded.data, roleDetailDecoded.success { + self.name = data.name + self.auditionRoleDetail = data + } else { + if let message = roleDetailDecoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + + do { + let applicantListDecoded = try jsonDecoder.decode(ApiResponse.self, from: applicantList) + if let data = applicantListDecoded.data, applicantListDecoded.success { + self.totalCount = data.totalCount + self.applicantList.append(contentsOf: data.items) + + if data.items.isEmpty { + isLast = true + } else { + page += 1 + } + } else { + if let message = applicantListDecoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.onFailure() + } + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.onFailure() + } + } + + self.isLoading = false + } + .store(in: &subscription) + } + + func getAuditionApplicantList() { + if !isLoading && !isLast { + isLoading = true + + repository.getAuditionApplicantList(auditionRoleId: auditionRoleId, sortType: sortType, page: page, size: pageSize) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + let responseData = response.data + self.isLoading = false + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.totalCount = data.totalCount + self.applicantList.append(contentsOf: data.items) + + if data.items.isEmpty { + isLast = true + } else { + page += 1 + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + } +} diff --git a/SodaLive/Sources/Audition/Role/GetAuditionRoleDetailResponse.swift b/SodaLive/Sources/Audition/Role/GetAuditionRoleDetailResponse.swift new file mode 100644 index 0000000..6e6be87 --- /dev/null +++ b/SodaLive/Sources/Audition/Role/GetAuditionRoleDetailResponse.swift @@ -0,0 +1,16 @@ +// +// GetAuditionRoleDetailResponse.swift +// SodaLive +// +// Created by klaus on 1/6/25. +// + +struct GetAuditionRoleDetailResponse: Decodable { + let auditionRoleId: Int + let name: String + let imageUrl: String + let information: String + let originalWorkUrl: String + let auditionScriptUrl: String + let isAlreadyApplicant: Bool +} diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 2d750c9..53fd302 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -206,6 +206,9 @@ struct ContentView: View { case .auditionDetail(let auditionId): AuditionDetailView(auditionId: auditionId) + case .auditionRoleDetail(let roleId): + AuditionRoleDetailView(roleId: roleId) + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading) diff --git a/SodaLive/Sources/CustomView/ExpandableTextView.swift b/SodaLive/Sources/CustomView/ExpandableTextView.swift index b780157..58c71b4 100644 --- a/SodaLive/Sources/CustomView/ExpandableTextView.swift +++ b/SodaLive/Sources/CustomView/ExpandableTextView.swift @@ -14,7 +14,7 @@ struct ExpandableTextView: View { let text: String var body: some View { - let customFont = UIFont(name: Font.medium.rawValue, size: 12) ?? UIFont.systemFont(ofSize: 12) + let customFont = UIFont(name: Font.medium.rawValue, size: 12.9) ?? UIFont.systemFont(ofSize: 12.9) let lineHeight = customFont.lineHeight VStack(alignment: .leading) {