오디션 상세 페이지 추가
This commit is contained in:
		| @@ -135,4 +135,6 @@ enum AppStep { | ||||
|     case blockList | ||||
|      | ||||
|     case myBox | ||||
|      | ||||
|     case auditionDetail(auditionId: Int) | ||||
| } | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import Moya | ||||
|  | ||||
| enum AuditionApi { | ||||
|     case getAuditionList(page: Int, size: Int) | ||||
|     case getAuditionDetail(auditionId: Int) | ||||
| } | ||||
|  | ||||
| extension AuditionApi: TargetType { | ||||
| @@ -22,13 +23,16 @@ extension AuditionApi: TargetType { | ||||
|              | ||||
|         case .getAuditionList: | ||||
|             return "/audition" | ||||
|              | ||||
|         case .getAuditionDetail(let auditionId): | ||||
|             return "/audition/\(auditionId)" | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     var method: Moya.Method { | ||||
|         switch self { | ||||
|              | ||||
|         case .getAuditionList: | ||||
|         case .getAuditionList, .getAuditionDetail: | ||||
|             return .get | ||||
|         } | ||||
|     } | ||||
| @@ -43,6 +47,9 @@ extension AuditionApi: TargetType { | ||||
|             ] as [String : Any] | ||||
|              | ||||
|             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) | ||||
|              | ||||
|         case .getAuditionDetail: | ||||
|             return .requestPlain | ||||
|         } | ||||
|     } | ||||
|      | ||||
|   | ||||
| @@ -21,8 +21,10 @@ struct AuditionItemView: View { | ||||
|                     .resizable() | ||||
|                     .aspectRatio(1000/530, contentMode: .fit) | ||||
|                     .frame(maxWidth: .infinity) | ||||
|                     .cornerRadius(6.7) | ||||
|                     .overlay( | ||||
|                         Color.black | ||||
|                             .cornerRadius(6.7) | ||||
|                             .opacity(item.isOff ? 0.7 : 0.0) | ||||
|                     ) | ||||
|             } | ||||
|   | ||||
| @@ -16,4 +16,8 @@ final class AuditionRepository { | ||||
|     func getAuditionList(page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getAuditionList(page: page, size: size)) | ||||
|     } | ||||
|      | ||||
|     func getAuditionDetail(auditionId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getAuditionDetail(auditionId: auditionId)) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -40,6 +40,12 @@ struct AuditionView: View { | ||||
|                                     } | ||||
|                                      | ||||
|                                     AuditionItemView(item: item) | ||||
|                                         .onTapGesture { | ||||
|                                             AppState.shared | ||||
|                                                 .setAppStep( | ||||
|                                                     step: .auditionDetail(auditionId: item.id) | ||||
|                                                 ) | ||||
|                                         } | ||||
|                                 } | ||||
|                             } else if $0 == viewModel.firstIsOffIndex { | ||||
|                                 VStack(alignment: .leading, spacing: 0) { | ||||
| @@ -67,9 +73,21 @@ struct AuditionView: View { | ||||
|                                      | ||||
|                                     AuditionItemView(item: item) | ||||
|                                         .padding(.top, 16.7) | ||||
|                                         .onTapGesture { | ||||
|                                             AppState.shared | ||||
|                                                 .setAppStep( | ||||
|                                                     step: .auditionDetail(auditionId: item.id) | ||||
|                                                 ) | ||||
|                                         } | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 AuditionItemView(item: item) | ||||
|                                     .onTapGesture { | ||||
|                                         AppState.shared | ||||
|                                             .setAppStep( | ||||
|                                                 step: .auditionDetail(auditionId: item.id) | ||||
|                                             ) | ||||
|                                     } | ||||
|                             } | ||||
|                              | ||||
|                             if $0 == viewModel.auditionList.count - 1 { | ||||
|   | ||||
| @@ -31,8 +31,6 @@ final class AuditionViewModel: ObservableObject { | ||||
|         if !isLast && !isLoading { | ||||
|             isLoading = true | ||||
|              | ||||
|             DEBUG_LOG("호출됨") | ||||
|              | ||||
|             repository.getAuditionList(page: page, size: pageSize) | ||||
|                 .sink { result in | ||||
|                     switch result { | ||||
|   | ||||
							
								
								
									
										83
									
								
								SodaLive/Sources/Audition/Detail/AuditionDetailView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								SodaLive/Sources/Audition/Detail/AuditionDetailView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| // | ||||
| //  AuditionDetailView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 1/6/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct AuditionDetailView: View { | ||||
|      | ||||
|     @StateObject var viewModel = AuditionDetailViewModel() | ||||
|      | ||||
|     let auditionId: Int | ||||
|      | ||||
|     var body: some View { | ||||
|         BaseView(isLoading: $viewModel.isLoading) { | ||||
|             VStack(spacing: 0) { | ||||
|                 DetailNavigationBar(title: viewModel.title) | ||||
|                  | ||||
|                 if let response = viewModel.response { | ||||
|                     ScrollView(.vertical, showsIndicators: false) { | ||||
|                         VStack(alignment: .leading, spacing: 0) { | ||||
|                             KFImage(URL(string: response.imageUrl)) | ||||
|                                 .cancelOnDisappear(true) | ||||
|                                 .downsampling(size: CGSize(width: 1000, height: 530)) | ||||
|                                 .resizable() | ||||
|                                 .aspectRatio(1000/530, contentMode: .fit) | ||||
|                                 .frame(maxWidth: .infinity) | ||||
|                                 .cornerRadius(6.7) | ||||
|                              | ||||
|                             Text("오디션 정보") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                                 .foregroundColor(Color.grayee) | ||||
|                                 .padding(.top, 15) | ||||
|                              | ||||
|                             ExpandableTextView(text: response.information) | ||||
|                                 .padding(.top, 13.3) | ||||
|                              | ||||
|                             Text("오디션 캐릭터") | ||||
|                                 .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                                 .foregroundColor(Color.grayee) | ||||
|                                 .padding(.vertical, 15) | ||||
|                              | ||||
|                             LazyVStack(spacing: 15) { | ||||
|                                 ForEach(0..<response.roleList.count, id: \.self) { | ||||
|                                     AuditionDetailRoleItemView(item: response.roleList[$0]) | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         .padding(.horizontal, 13.3) | ||||
|                         .padding(.vertical, 8) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .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() | ||||
|                 } | ||||
|             } | ||||
|             .onAppear { | ||||
|                 viewModel.getAuditionDetail(auditionId: auditionId) { | ||||
|                     AppState.shared.back() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     AuditionDetailView(auditionId: 1) | ||||
| } | ||||
| @@ -0,0 +1,68 @@ | ||||
| // | ||||
| //  AuditionDetailViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 1/6/25. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import Combine | ||||
|  | ||||
| final class AuditionDetailViewModel: ObservableObject { | ||||
|      | ||||
|     private let repository = AuditionRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|     @Published var isLoading = false | ||||
|      | ||||
|     @Published var response: GetAuditionDetailResponse? = nil | ||||
|     @Published var title: String = "보이스온" | ||||
|      | ||||
|     func getAuditionDetail(auditionId: Int, onFailure: () -> Void) { | ||||
|         isLoading = true | ||||
|          | ||||
|         repository.getAuditionDetail(auditionId: auditionId) | ||||
|             .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 | ||||
|                  | ||||
|                 do { | ||||
|                     let jsonDecoder = JSONDecoder() | ||||
|                     let decoded = try jsonDecoder.decode(ApiResponse<GetAuditionDetailResponse>.self, from: responseData) | ||||
|                      | ||||
|                     if let data = decoded.data, decoded.success { | ||||
|                         self.response = data | ||||
|                         self.title = data.title | ||||
|                     } else { | ||||
|                         if let message = decoded.message { | ||||
|                             self.errorMessage = message | ||||
|                         } else { | ||||
|                             self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         } | ||||
|                          | ||||
|                         self.isShowPopup = true | ||||
|                         DispatchQueue.main.asyncAfter(deadline: .now() + 2) { | ||||
|                             onFailure() | ||||
|                         } | ||||
|                     } | ||||
|                 } catch { | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                     DispatchQueue.main.asyncAfter(deadline: .now() + 2) { | ||||
|                         onFailure() | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 self.isLoading = false | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,21 @@ | ||||
| // | ||||
| //  GetAuditionDetailResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 1/6/25. | ||||
| // | ||||
|  | ||||
| struct GetAuditionDetailResponse: Decodable { | ||||
|     let auditionId: Int | ||||
|     let title: String | ||||
|     let imageUrl: String | ||||
|     let information: String | ||||
|     let roleList: [GetAuditionRoleListData] | ||||
| } | ||||
|  | ||||
| struct GetAuditionRoleListData: Decodable { | ||||
|     let roleId: Int | ||||
|     let name: String | ||||
|     let imageUrl: String | ||||
|     let isComplete: Bool | ||||
| } | ||||
| @@ -0,0 +1,63 @@ | ||||
| // | ||||
| //  AuditionDetailRoleItemView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 1/6/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct AuditionDetailRoleItemView: View { | ||||
|      | ||||
|     let item: GetAuditionRoleListData | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 10) { | ||||
|             ZStack(alignment: .topLeading) { | ||||
|                 KFImage(URL(string: item.imageUrl)) | ||||
|                     .cancelOnDisappear(true) | ||||
|                     .downsampling(size: CGSize(width: 1000, height: 350)) | ||||
|                     .resizable() | ||||
|                     .aspectRatio(1000/350, contentMode: .fit) | ||||
|                     .frame(maxWidth: .infinity) | ||||
|                     .cornerRadius(6.7) | ||||
|                     .overlay( | ||||
|                         Color.black | ||||
|                             .cornerRadius(6.7) | ||||
|                             .opacity(item.isComplete ? 0.7 : 0.0) | ||||
|                     ) | ||||
|                  | ||||
|                 Text(item.isComplete ? "모집완료" : "모집중") | ||||
|                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                     .foregroundColor(Color.white) | ||||
|                     .padding(.horizontal, 9) | ||||
|                     .padding(.vertical, 3) | ||||
|                     .background( | ||||
|                         item.isComplete ? Color.gray90 : Color.button | ||||
|                     ) | ||||
|                     .cornerRadius(13.3) | ||||
|                     .padding(.top, 7) | ||||
|                     .padding(.leading, 7) | ||||
|             } | ||||
|             .frame(maxWidth: .infinity) | ||||
|              | ||||
|             Text(item.name) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(Color.grayee) | ||||
|                 .lineLimit(1) | ||||
|                 .truncationMode(.tail) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     AuditionDetailRoleItemView( | ||||
|         item: GetAuditionRoleListData( | ||||
|             roleId: 1, | ||||
|             name: "[남주] 김희준", | ||||
|             imageUrl: "https://test-cf.sodalive.net/audition/role/7/audition_role-dc4174e1-10b5-4a97-8379-c7a9336ab230-6691-1735908236571", | ||||
|             isComplete: false | ||||
|         ) | ||||
|     ) | ||||
| } | ||||
| @@ -203,6 +203,9 @@ struct ContentView: View { | ||||
|             case .myBox: | ||||
|                 ContentBoxView() | ||||
|                  | ||||
|             case .auditionDetail(let auditionId): | ||||
|                 AuditionDetailView(auditionId: auditionId) | ||||
|                  | ||||
|             default: | ||||
|                 EmptyView() | ||||
|                     .frame(width: 0, height: 0, alignment: .topLeading) | ||||
|   | ||||
							
								
								
									
										57
									
								
								SodaLive/Sources/CustomView/ExpandableTextView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								SodaLive/Sources/CustomView/ExpandableTextView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| // | ||||
| //  ExpandableTextView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 1/6/25. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct ExpandableTextView: View { | ||||
|     @State private var isExpanded = false // 텍스트 확장 여부 상태 | ||||
|     @State private var isTruncated = false // 텍스트 잘림 여부 상태 | ||||
|      | ||||
|     let text: String | ||||
|      | ||||
|     var body: some View { | ||||
|         let customFont = UIFont(name: Font.medium.rawValue, size: 12) ?? UIFont.systemFont(ofSize: 12) | ||||
|         let lineHeight = customFont.lineHeight | ||||
|          | ||||
|         VStack(alignment: .leading) { | ||||
|             Text(text) | ||||
|                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                 .foregroundColor(Color.gray77) | ||||
|                 .lineLimit(isExpanded ? nil : 3) // 확장 시 전체 표시, 아니면 3줄로 제한 | ||||
|                 .truncationMode(.tail) | ||||
|                 .background( | ||||
|                     GeometryReader { proxy in | ||||
|                         Color.clear | ||||
|                             .onAppear { | ||||
|                                 let size = proxy.size | ||||
|                                 let maxHeight = lineHeight * 3 // 커스텀 폰트의 라인 높이를 사용 | ||||
|                                 isTruncated = size.height > maxHeight && !isExpanded | ||||
|                             } | ||||
|                     } | ||||
|                 ) | ||||
|                 .frame(maxWidth: .infinity, alignment: .leading) | ||||
|              | ||||
|             if isTruncated || isExpanded { | ||||
|                 HStack(spacing: 6.7) { | ||||
|                     Spacer() | ||||
|                     Image(isExpanded ? "ic_live_detail_top" : "ic_live_detail_bottom") | ||||
|                      | ||||
|                     Text(isExpanded ? "접기" : "펼치기") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                         .foregroundColor(Color.graybb) | ||||
|                     Spacer() | ||||
|                 } | ||||
|                 .contentShape(Rectangle()) | ||||
|                 .onTapGesture { isExpanded.toggle() } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     ExpandableTextView(text: "여기에 아주 긴 텍스트를 넣어보세요. SwiftUI에서 Text는 길이에 따라 Truncated 될 수 있습니다. 이 예제는 3줄로 제한하고, 그 이상이면 버튼을 표시합니다. 자세히 보기를 눌러 내용을 확장하거나, 간단히 보기를 눌러 다시 축소할 수 있습니다.") | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 Yu Sung
					Yu Sung