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 0000000..7c0758c Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_audition_pause.imageset/ic_audition_pause.png differ 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 0000000..542d947 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_audition_play.imageset/ic_audition_play.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_heart_vote.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_heart_vote.imageset/Contents.json new file mode 100644 index 0000000..e3508fd --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_heart_vote.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_heart_vote.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_heart_vote.imageset/ic_heart_vote.png b/SodaLive/Resources/Assets.xcassets/ic_heart_vote.imageset/ic_heart_vote.png new file mode 100644 index 0000000..339bf6c Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_heart_vote.imageset/ic_heart_vote.png differ diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 8556b39..89d20cd 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -137,4 +137,6 @@ enum AppStep { case myBox case auditionDetail(auditionId: Int) + + case auditionRoleDetail(roleId: Int) } diff --git a/SodaLive/Sources/Audition/Applicant/AuditionApplicantItemView.swift b/SodaLive/Sources/Audition/Applicant/AuditionApplicantItemView.swift new file mode 100644 index 0000000..6ce60cb --- /dev/null +++ b/SodaLive/Sources/Audition/Applicant/AuditionApplicantItemView.swift @@ -0,0 +1,72 @@ +// +// AuditionApplicantItemView.swift +// SodaLive +// +// Created by klaus on 1/7/25. +// + +import SwiftUI +import Kingfisher + +struct AuditionApplicantItemView: View { + + let item: GetAuditionRoleApplicantItem + + var body: some View { + VStack(spacing: 5.3) { + HStack(spacing: 13.3) { + ZStack { + KFImage(URL(string: item.profileImageUrl)) + .cancelOnDisappear(true) + .downsampling(size: CGSize(width: 40, height: 40)) + .resizable() + .aspectRatio(1, contentMode: .fit) + .scaledToFill() + .frame(width: 40, height: 40) + .clipShape(Circle()) + + Image("ic_audition_play") + .onTapGesture { + } + } + + VStack(spacing: 8) { + HStack(spacing: 0) { + Text(item.nickname.count > 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) {