From 739a9b42b764d1cad2ca4e2e2f6b5a38814494b0 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 6 Jan 2025 18:31:49 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=A4=EB=94=94=EC=85=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/App/AppStep.swift | 2 + SodaLive/Sources/Audition/AuditionApi.swift | 9 +- .../Sources/Audition/AuditionItemView.swift | 2 + .../Sources/Audition/AuditionRepository.swift | 4 + SodaLive/Sources/Audition/AuditionView.swift | 18 ++++ .../Sources/Audition/AuditionViewModel.swift | 2 - .../Audition/Detail/AuditionDetailView.swift | 83 +++++++++++++++++++ .../Detail/AuditionDetailViewModel.swift | 68 +++++++++++++++ .../Detail/GetAuditionDetailResponse.swift | 21 +++++ .../Role/AuditionDetailRoleItemView.swift | 63 ++++++++++++++ SodaLive/Sources/ContentView.swift | 3 + .../CustomView/ExpandableTextView.swift | 57 +++++++++++++ 12 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 SodaLive/Sources/Audition/Detail/AuditionDetailView.swift create mode 100644 SodaLive/Sources/Audition/Detail/AuditionDetailViewModel.swift create mode 100644 SodaLive/Sources/Audition/Detail/GetAuditionDetailResponse.swift create mode 100644 SodaLive/Sources/Audition/Role/AuditionDetailRoleItemView.swift create mode 100644 SodaLive/Sources/CustomView/ExpandableTextView.swift diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index dd95cab..8556b39 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -135,4 +135,6 @@ enum AppStep { case blockList case myBox + + case auditionDetail(auditionId: Int) } diff --git a/SodaLive/Sources/Audition/AuditionApi.swift b/SodaLive/Sources/Audition/AuditionApi.swift index c93864c..8b2918a 100644 --- a/SodaLive/Sources/Audition/AuditionApi.swift +++ b/SodaLive/Sources/Audition/AuditionApi.swift @@ -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 } } diff --git a/SodaLive/Sources/Audition/AuditionItemView.swift b/SodaLive/Sources/Audition/AuditionItemView.swift index a2c64d3..6e3794c 100644 --- a/SodaLive/Sources/Audition/AuditionItemView.swift +++ b/SodaLive/Sources/Audition/AuditionItemView.swift @@ -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) ) } diff --git a/SodaLive/Sources/Audition/AuditionRepository.swift b/SodaLive/Sources/Audition/AuditionRepository.swift index c714f05..9447a2e 100644 --- a/SodaLive/Sources/Audition/AuditionRepository.swift +++ b/SodaLive/Sources/Audition/AuditionRepository.swift @@ -16,4 +16,8 @@ final class AuditionRepository { func getAuditionList(page: Int, size: Int) -> AnyPublisher { return api.requestPublisher(.getAuditionList(page: page, size: size)) } + + func getAuditionDetail(auditionId: Int) -> AnyPublisher { + return api.requestPublisher(.getAuditionDetail(auditionId: auditionId)) + } } diff --git a/SodaLive/Sources/Audition/AuditionView.swift b/SodaLive/Sources/Audition/AuditionView.swift index dbfab7f..c1a141a 100644 --- a/SodaLive/Sources/Audition/AuditionView.swift +++ b/SodaLive/Sources/Audition/AuditionView.swift @@ -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 { diff --git a/SodaLive/Sources/Audition/AuditionViewModel.swift b/SodaLive/Sources/Audition/AuditionViewModel.swift index ea0f974..ae18024 100644 --- a/SodaLive/Sources/Audition/AuditionViewModel.swift +++ b/SodaLive/Sources/Audition/AuditionViewModel.swift @@ -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 { diff --git a/SodaLive/Sources/Audition/Detail/AuditionDetailView.swift b/SodaLive/Sources/Audition/Detail/AuditionDetailView.swift new file mode 100644 index 0000000..6fb365f --- /dev/null +++ b/SodaLive/Sources/Audition/Detail/AuditionDetailView.swift @@ -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..() + + @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.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) + } +} diff --git a/SodaLive/Sources/Audition/Detail/GetAuditionDetailResponse.swift b/SodaLive/Sources/Audition/Detail/GetAuditionDetailResponse.swift new file mode 100644 index 0000000..0df1d0d --- /dev/null +++ b/SodaLive/Sources/Audition/Detail/GetAuditionDetailResponse.swift @@ -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 +} diff --git a/SodaLive/Sources/Audition/Role/AuditionDetailRoleItemView.swift b/SodaLive/Sources/Audition/Role/AuditionDetailRoleItemView.swift new file mode 100644 index 0000000..57f1a78 --- /dev/null +++ b/SodaLive/Sources/Audition/Role/AuditionDetailRoleItemView.swift @@ -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 + ) + ) +} diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index fb2b4ed..2d750c9 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -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) diff --git a/SodaLive/Sources/CustomView/ExpandableTextView.swift b/SodaLive/Sources/CustomView/ExpandableTextView.swift new file mode 100644 index 0000000..b780157 --- /dev/null +++ b/SodaLive/Sources/CustomView/ExpandableTextView.swift @@ -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줄로 제한하고, 그 이상이면 버튼을 표시합니다. 자세히 보기를 눌러 내용을 확장하거나, 간단히 보기를 눌러 다시 축소할 수 있습니다.") +}