시리즈 상세 추가
This commit is contained in:
		| @@ -233,6 +233,15 @@ | ||||
|         "revision" : "d5a7d856655d5c91f891c2b69d982c30fd5c7bdf", | ||||
|         "version" : "2.1.0" | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       "identity" : "taglayoutview", | ||||
|       "kind" : "remoteSourceControl", | ||||
|       "location" : "https://github.com/yotsu12/TagLayoutView", | ||||
|       "state" : { | ||||
|         "branch" : "master", | ||||
|         "revision" : "815deadaca2b65edb03ec2fe25d0ce300d2eb7b3" | ||||
|       } | ||||
|     } | ||||
|   ], | ||||
|   "version" : 2 | ||||
|   | ||||
| @@ -0,0 +1,140 @@ | ||||
| // | ||||
| //  SeriesContentListItemView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 4/30/24. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct SeriesContentListItemView: View { | ||||
|      | ||||
|     let item: GetSeriesContentListItem | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(spacing: 12) { | ||||
|             HStack(spacing: 11) { | ||||
|                 KFImage(URL(string: item.coverImage)) | ||||
|                     .resizable() | ||||
|                     .scaledToFit() | ||||
|                     .frame(width: 66.7, height: 66.7) | ||||
|                     .cornerRadius(5.3) | ||||
|                  | ||||
|                 VStack(alignment: .leading, spacing: 2.7) { | ||||
|                     Text(item.duration) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 10)) | ||||
|                         .foregroundColor(Color.gray77) | ||||
|                         .padding(2.7) | ||||
|                         .background(Color.gray22) | ||||
|                         .cornerRadius(2.6) | ||||
|                      | ||||
|                     Text(item.title) | ||||
|                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                         .foregroundColor(Color.grayd2) | ||||
|                 } | ||||
|                  | ||||
|                 Spacer() | ||||
|                  | ||||
|                 if item.isOwned { | ||||
|                     Text("소장중") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                         .foregroundColor(Color.gray11) | ||||
|                         .padding(.horizontal, 5.3) | ||||
|                         .padding(.vertical, 2.7) | ||||
|                         .background(Color(hex: "b1ef2c")) | ||||
|                         .cornerRadius(2.6) | ||||
|                 } else if item.isRented { | ||||
|                     Text("대여중") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                         .foregroundColor(Color.white) | ||||
|                         .padding(.horizontal, 5.3) | ||||
|                         .padding(.vertical, 2.7) | ||||
|                         .background(Color(hex: "660fd4")) | ||||
|                         .cornerRadius(2.6) | ||||
|                 } else if item.price > 0 { | ||||
|                     HStack(spacing: 5.3) { | ||||
|                         Image("ic_can") | ||||
|                          | ||||
|                         Text("\(item.price)") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                             .foregroundColor(Color(hex: "909090")) | ||||
|                     } | ||||
|                 } else { | ||||
|                     Text("무료") | ||||
|                         .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||
|                         .foregroundColor(Color.white) | ||||
|                         .padding(.horizontal, 5.3) | ||||
|                         .padding(.vertical, 2.7) | ||||
|                         .background(Color(hex: "cf5c37")) | ||||
|                         .cornerRadius(2.6) | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             Rectangle() | ||||
|                 .foregroundColor(Color.grayd8) | ||||
|                 .frame(maxWidth: .infinity) | ||||
|                 .frame(height: 1) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview("무료") { | ||||
|     SeriesContentListItemView( | ||||
|         item: GetSeriesContentListItem( | ||||
|             contentId: 1, | ||||
|             title: "[무료] 두근두근 연애 연구부 EP1", | ||||
|             coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|             releaseDate: "", | ||||
|             duration: "00:14:59", | ||||
|             price: 0, | ||||
|             isRented: false, | ||||
|             isOwned: false | ||||
|         ) | ||||
|     ) | ||||
| } | ||||
|  | ||||
| #Preview("유료") { | ||||
|     SeriesContentListItemView( | ||||
|         item: GetSeriesContentListItem( | ||||
|             contentId: 1, | ||||
|             title: "두근두근 연애 연구부 EP1", | ||||
|             coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|             releaseDate: "", | ||||
|             duration: "00:14:59", | ||||
|             price: 100, | ||||
|             isRented: false, | ||||
|             isOwned: false | ||||
|         ) | ||||
|     ) | ||||
| } | ||||
|  | ||||
| #Preview("대여") { | ||||
|     SeriesContentListItemView( | ||||
|         item: GetSeriesContentListItem( | ||||
|             contentId: 1, | ||||
|             title: "두근두근 연애 연구부 EP1", | ||||
|             coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|             releaseDate: "", | ||||
|             duration: "00:14:59", | ||||
|             price: 200, | ||||
|             isRented: true, | ||||
|             isOwned: false | ||||
|         ) | ||||
|     ) | ||||
| } | ||||
|  | ||||
| #Preview("소장") { | ||||
|     SeriesContentListItemView( | ||||
|         item: GetSeriesContentListItem( | ||||
|             contentId: 1, | ||||
|             title: "두근두근 연애 연구부 EP1", | ||||
|             coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|             releaseDate: "", | ||||
|             duration: "00:14:59", | ||||
|             price: 300, | ||||
|             isRented: false, | ||||
|             isOwned: true | ||||
|         ) | ||||
|     ) | ||||
| } | ||||
| @@ -0,0 +1,24 @@ | ||||
| // | ||||
| //  GetSeriesContentListResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 4/29/24. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct GetSeriesContentListResponse: Decodable { | ||||
|     let totalCount: Int | ||||
|     let items: [GetSeriesContentListItem] | ||||
| } | ||||
|  | ||||
| struct GetSeriesContentListItem: Decodable { | ||||
|     let contentId: Int | ||||
|     let title: String | ||||
|     let coverImage: String | ||||
|     let releaseDate: String | ||||
|     let duration: String | ||||
|     let price: Int | ||||
|     let isRented: Bool | ||||
|     let isOwned: Bool | ||||
| } | ||||
| @@ -0,0 +1,37 @@ | ||||
| // | ||||
| //  GetSeriesDetailResponse.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 4/29/24. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| struct GetSeriesDetailResponse: Decodable { | ||||
|     let seriesId: Int | ||||
|     let title: String | ||||
|     let coverImage: String | ||||
|     let introduction: String | ||||
|     let genre: String | ||||
|     let isAdult: Bool | ||||
|     let writer: String? | ||||
|     let studio: String? | ||||
|     let publishedDate: String | ||||
|     let creator: GetSeriesDetailCreator | ||||
|     let rentalMinPrice: Int | ||||
|     let rentalMaxPrice: Int | ||||
|     let rentalPeriod: Int | ||||
|     let minPrice: Int | ||||
|     let maxPrice: Int | ||||
|     let keywordList: [String] | ||||
|     let publishedDaysOfWeek: String | ||||
|     let contentList: [GetSeriesContentListItem] | ||||
|     let contentCount: Int | ||||
| } | ||||
|  | ||||
| struct GetSeriesDetailCreator: Decodable { | ||||
|     let creatorId: Int | ||||
|     let nickname: String | ||||
|     let profileImage: String | ||||
|     let isFollow: Bool | ||||
| } | ||||
| @@ -0,0 +1,106 @@ | ||||
| // | ||||
| //  SeriesDetailHomeView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 4/29/24. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct SeriesDetailHomeView: View { | ||||
|      | ||||
|     let title: String | ||||
|     let seriesId: Int | ||||
|     let contentCount: Int | ||||
|     let contentList: [GetSeriesContentListItem] | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(spacing: 0) { | ||||
|             HStack(spacing: 0) { | ||||
|                 Text("전체회차 듣기") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 16)) | ||||
|                     .foregroundColor(Color.button) | ||||
|                  | ||||
|                 Text(" (\(contentCount))") | ||||
|                     .font(.custom(Font.light.rawValue, size: 16)) | ||||
|                     .foregroundColor(Color.button) | ||||
|             } | ||||
|             .frame(maxWidth: .infinity) | ||||
|             .padding(.vertical, 13.3) | ||||
|             .background(Color.bg) | ||||
|             .cornerRadius(5.3) | ||||
|             .overlay( | ||||
|                 RoundedRectangle(cornerRadius: 5.3) | ||||
|                     .stroke() | ||||
|                     .foregroundColor(Color.button) | ||||
|             ) | ||||
|             .padding(.top, 16) | ||||
|             .onTapGesture {} | ||||
|              | ||||
|             VStack(spacing: 8) { | ||||
|                 ForEach(0..<contentList.count, id: \.self) { | ||||
|                     let item = contentList[$0] | ||||
|                      | ||||
|                     SeriesContentListItemView(item: item) | ||||
|                         .contentShape(Rectangle()) | ||||
|                         .onTapGesture { | ||||
|                             AppState.shared | ||||
|                                 .setAppStep(step: .contentDetail(contentId: item.contentId)) | ||||
|                         } | ||||
|                 } | ||||
|             } | ||||
|             .padding(.top, 16) | ||||
|         } | ||||
|         .padding(.horizontal, 13.3) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     SeriesDetailHomeView( | ||||
|         title: "변호사 우영우", | ||||
|         seriesId: 0, | ||||
|         contentCount: 10, | ||||
|         contentList: [ | ||||
|             GetSeriesContentListItem( | ||||
|                 contentId: 1, | ||||
|                 title: "[무료] 두근두근 연애 연구부 EP1", | ||||
|                 coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 releaseDate: "", | ||||
|                 duration: "00:14:59", | ||||
|                 price: 0, | ||||
|                 isRented: false, | ||||
|                 isOwned: false | ||||
|             ), | ||||
|             GetSeriesContentListItem( | ||||
|                 contentId: 2, | ||||
|                 title: "두근두근 연애 연구부 EP2", | ||||
|                 coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 releaseDate: "", | ||||
|                 duration: "00:14:59", | ||||
|                 price: 100, | ||||
|                 isRented: false, | ||||
|                 isOwned: false | ||||
|             ), | ||||
|             GetSeriesContentListItem( | ||||
|                 contentId: 3, | ||||
|                 title: "두근두근 연애 연구부 EP3", | ||||
|                 coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 releaseDate: "", | ||||
|                 duration: "00:14:59", | ||||
|                 price: 100, | ||||
|                 isRented: true, | ||||
|                 isOwned: false | ||||
|             ), | ||||
|             GetSeriesContentListItem( | ||||
|                 contentId: 4, | ||||
|                 title: "두근두근 연애 연구부 EP4", | ||||
|                 coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||
|                 releaseDate: "", | ||||
|                 duration: "00:14:59", | ||||
|                 price: 100, | ||||
|                 isRented: false, | ||||
|                 isOwned: true | ||||
|             ) | ||||
|         ] | ||||
|     ) | ||||
| } | ||||
| @@ -0,0 +1,165 @@ | ||||
| // | ||||
| //  SeriesDetailIntroductionView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 4/29/24. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import TagLayoutView | ||||
|  | ||||
| struct SeriesDetailIntroductionView: View { | ||||
|      | ||||
|     let width: CGFloat | ||||
|     let seriesDetail: GetSeriesDetailResponse | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(alignment: .leading, spacing: 16) { | ||||
|             Text("키워드") | ||||
|                 .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                 .foregroundColor(Color.grayee) | ||||
|                 .padding(.top, 16) | ||||
|                 .padding(.horizontal, 13.3) | ||||
|              | ||||
|             TagLayoutView( | ||||
|                 seriesDetail.keywordList, | ||||
|                 tagFont: UIFont(name: Font.medium.rawValue, size: 12)!, | ||||
|                 padding: 5.3, | ||||
|                 parentWidth: width | ||||
|             ) { | ||||
|                 SeriesKeywordChipView(keyword: $0) | ||||
|             } | ||||
|             .padding(.horizontal, 13.3) | ||||
|              | ||||
|             Rectangle() | ||||
|                 .frame(height: 6.7) | ||||
|                 .foregroundColor(Color.gray22) | ||||
|              | ||||
|             VStack(alignment: .leading, spacing: 13.3) { | ||||
|                 Text("작품소개") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                     .foregroundColor(Color.grayee) | ||||
|                  | ||||
|                 Text(seriesDetail.introduction) | ||||
|                     .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                     .foregroundColor(Color.gray77) | ||||
|                     .lineSpacing(4) | ||||
|             } | ||||
|             .padding(.horizontal, 13.3) | ||||
|              | ||||
|             Rectangle() | ||||
|                 .frame(height: 6.7) | ||||
|                 .foregroundColor(Color.gray22) | ||||
|              | ||||
|             VStack(alignment: .leading, spacing: 16) { | ||||
|                 Text("상세정보") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                     .foregroundColor(Color.grayee) | ||||
|                  | ||||
|                 HStack(spacing: 30) { | ||||
|                     VStack(alignment: .leading, spacing: 13.3) { | ||||
|                         Text("장르") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.gray77) | ||||
|                          | ||||
|                         Text("연령제한") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.gray77) | ||||
|                          | ||||
|                         if let _ = seriesDetail.writer { | ||||
|                             Text("작가") | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                                 .foregroundColor(Color.gray77) | ||||
|                         } | ||||
|                          | ||||
|                         if let _ = seriesDetail.studio { | ||||
|                             Text("제작사") | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                                 .foregroundColor(Color.gray77) | ||||
|                         } | ||||
|                          | ||||
|                         Text("연재") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.gray77) | ||||
|                          | ||||
|                         Text("출시일") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.gray77) | ||||
|                     } | ||||
|                      | ||||
|                     VStack(alignment: .leading, spacing: 13.3) { | ||||
|                         Text(seriesDetail.genre) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.white) | ||||
|                          | ||||
|                         Text(seriesDetail.isAdult ? "19세 이상" : "전체연령가") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.white) | ||||
|                          | ||||
|                         if let writer = seriesDetail.writer { | ||||
|                             Text(writer) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                                 .foregroundColor(Color.white) | ||||
|                         } | ||||
|                          | ||||
|                         if let studio = seriesDetail.studio { | ||||
|                             Text(studio) | ||||
|                                 .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                                 .foregroundColor(Color.white) | ||||
|                         } | ||||
|                          | ||||
|                         Text(seriesDetail.publishedDaysOfWeek == "랜덤" ? seriesDetail.publishedDaysOfWeek : "\(seriesDetail.publishedDaysOfWeek)요일") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.white) | ||||
|                          | ||||
|                         Text(seriesDetail.publishedDate) | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.white) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .padding(.horizontal, 13.3) | ||||
|              | ||||
|             VStack(alignment: .leading, spacing: 13.3) { | ||||
|                 Text("가격") | ||||
|                     .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||
|                     .foregroundColor(Color.grayee) | ||||
|                  | ||||
|                 HStack(spacing: 30) { | ||||
|                     VStack(alignment: .leading, spacing: 13.3) { | ||||
|                         Text("대여") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.gray77) | ||||
|                          | ||||
|                         Text("소장") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.gray77) | ||||
|                     } | ||||
|                      | ||||
|                     VStack(alignment: .leading, spacing: 13.3) { | ||||
|                         Text("\(calculatePriceInfo(seriesDetail.rentalMinPrice, seriesDetail.rentalMaxPrice)) (15일)") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.button) | ||||
|                          | ||||
|                         Text("\(calculatePriceInfo(seriesDetail.minPrice, seriesDetail.maxPrice))") | ||||
|                             .font(.custom(Font.medium.rawValue, size: 14.7)) | ||||
|                             .foregroundColor(Color.button) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             .padding(.horizontal, 13.3) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     func calculatePriceInfo(_ minPrice: Int, _ maxPrice: Int) -> String { | ||||
|         if minPrice == maxPrice { | ||||
|             if maxPrice == 0 { | ||||
|                 return "무료" | ||||
|             } else { | ||||
|                 return "\(maxPrice)" | ||||
|             } | ||||
|         } else { | ||||
|             return "\(minPrice == 0 ? "무료" : "\(minPrice)") ~ \(maxPrice)캔" | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										195
									
								
								SodaLive/Sources/Content/Series/Detail/SeriesDetailView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								SodaLive/Sources/Content/Series/Detail/SeriesDetailView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,195 @@ | ||||
| // | ||||
| //  SeriesDetailView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 4/29/24. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
| import Kingfisher | ||||
|  | ||||
| struct SeriesDetailView: View { | ||||
|      | ||||
|     @ObservedObject var viewModel = SeriesDetailViewModel() | ||||
|      | ||||
|     let seriesId: Int | ||||
|      | ||||
|     var body: some View { | ||||
|         BaseView(isLoading: $viewModel.isLoading) { | ||||
|             ZStack(alignment: .top) { | ||||
|                 Color.gray11.ignoresSafeArea() | ||||
|                  | ||||
|                 if let seriesDetail = viewModel.seriesDetail { | ||||
|                     KFImage(URL(string: seriesDetail.coverImage)) | ||||
|                         .resizable() | ||||
|                         .scaledToFit() | ||||
|                         .frame(maxWidth: .infinity) | ||||
|                         .blur(radius: 25) | ||||
|                      | ||||
|                     ScrollView(.vertical, showsIndicators: false) { | ||||
|                         VStack(spacing: 0) { | ||||
|                             HStack(spacing: 0) { | ||||
|                                 Image("ic_back") | ||||
|                                     .resizable() | ||||
|                                     .frame(width: 20, height: 20) | ||||
|                                     .onTapGesture { AppState.shared.back() } | ||||
|                                  | ||||
|                                 Spacer() | ||||
|                             } | ||||
|                             .padding(.horizontal, 13.3) | ||||
|                             .frame(height: 50) | ||||
|                              | ||||
|                             ZStack { | ||||
|                                 Rectangle() | ||||
|                                     .frame(width: screenSize().width, height: 94) | ||||
|                                     .foregroundColor(Color.gray11) | ||||
|                                     .cornerRadius(21.3, corners: [.topLeft, .topRight]) | ||||
|                                     .padding(.top, 94) | ||||
|                                  | ||||
|                                 KFImage(URL(string: seriesDetail.coverImage)) | ||||
|                                     .resizable() | ||||
|                                     .scaledToFit() | ||||
|                                     .cornerRadius(5) | ||||
|                                     .frame(width: 133.3, height: 188) | ||||
|                                  | ||||
|                             } | ||||
|                              | ||||
|                             VStack(alignment: .leading, spacing: 0) { | ||||
|                                 Text(seriesDetail.title) | ||||
|                                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||
|                                     .foregroundColor(Color.grayee) | ||||
|                                     .padding(.horizontal, 13.3) | ||||
|                                     .padding(.top, 24) | ||||
|                                  | ||||
|                                 HStack(spacing: 5.3) { | ||||
|                                     Text(seriesDetail.genre) | ||||
|                                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                         .foregroundColor(Color(hex: "3bac6a")) | ||||
|                                         .padding(.horizontal, 5.3) | ||||
|                                         .padding(.vertical, 3.3) | ||||
|                                         .background(Color(hex: "28312b")) | ||||
|                                         .cornerRadius(2.6) | ||||
|                                      | ||||
|                                     if seriesDetail.isAdult { | ||||
|                                         Text("19세") | ||||
|                                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                             .foregroundColor(Color(hex: "f1291c")) | ||||
|                                             .padding(.horizontal, 5.3) | ||||
|                                             .padding(.vertical, 3.3) | ||||
|                                             .background(Color(hex: "312827")) | ||||
|                                             .cornerRadius(2.6) | ||||
|                                     } else { | ||||
|                                         Text("전체연령가") | ||||
|                                             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                             .foregroundColor(Color(hex: "d2d2d2")) | ||||
|                                             .padding(.horizontal, 5.3) | ||||
|                                             .padding(.vertical, 3.3) | ||||
|                                             .background(Color(hex: "222222")) | ||||
|                                             .cornerRadius(2.6) | ||||
|                                     } | ||||
|                                      | ||||
|                                     Text(seriesDetail.publishedDaysOfWeek) | ||||
|                                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                         .foregroundColor(Color(hex: "909090")) | ||||
|                                 } | ||||
|                                 .padding(.top, 8) | ||||
|                                 .padding(.horizontal, 13.3) | ||||
|                                  | ||||
|                                 ScrollView(.horizontal, showsIndicators: false) { | ||||
|                                     HStack(spacing: 5.3) { | ||||
|                                         ForEach(0..<seriesDetail.keywordList.count, id: \.self) { | ||||
|                                             SeriesKeywordChipView(keyword: seriesDetail.keywordList[$0]) | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                                 .padding(.top, 16) | ||||
|                                 .padding(.horizontal, 13.3) | ||||
|                                  | ||||
|                                 HStack(spacing: 5.3) { | ||||
|                                     KFImage(URL(string: seriesDetail.creator.profileImage)) | ||||
|                                         .resizable() | ||||
|                                         .scaledToFit() | ||||
|                                         .clipShape(Circle()) | ||||
|                                         .frame(width: 26.7, height: 26.7) | ||||
|                                      | ||||
|                                     Text(seriesDetail.creator.nickname) | ||||
|                                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|                                         .foregroundColor(Color(hex: "909090")) | ||||
|                                      | ||||
|                                     Spacer() | ||||
|                                      | ||||
|                                     if seriesDetail.creator.creatorId != UserDefaults.int(forKey: .userId) { | ||||
|                                         Image(viewModel.isFollow ? "btn_following_big" : "btn_follow_big") | ||||
|                                             .onTapGesture { | ||||
|                                                 if viewModel.isFollow { | ||||
|                                                     viewModel.unFollow(seriesDetail.creator.creatorId) | ||||
|                                                 } else { | ||||
|                                                     viewModel.follow(seriesDetail.creator.creatorId) | ||||
|                                                 } | ||||
|                                             } | ||||
|                                     } | ||||
|                                 } | ||||
|                                 .padding(.top, 16) | ||||
|                                 .padding(.horizontal, 13.3) | ||||
|                                  | ||||
|                                 HStack(spacing: 0) { | ||||
|                                     SeriesDetailTabView( | ||||
|                                         title: "홈", | ||||
|                                         width: screenSize().width / 2, | ||||
|                                         isSelected: viewModel.currentTab == .home | ||||
|                                     ) { | ||||
|                                         if viewModel.currentTab != .home { | ||||
|                                             viewModel.currentTab = .home | ||||
|                                         } | ||||
|                                     } | ||||
|                                      | ||||
|                                     SeriesDetailTabView( | ||||
|                                         title: "작품소개", | ||||
|                                         width: screenSize().width / 2, | ||||
|                                         isSelected: viewModel.currentTab == .introduction | ||||
|                                     ) { | ||||
|                                         if viewModel.currentTab != .introduction { | ||||
|                                             viewModel.currentTab = .introduction | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                                 .padding(.top, 16) | ||||
|                                  | ||||
|                                 Rectangle() | ||||
|                                     .foregroundColor(Color.gray90.opacity(0.5)) | ||||
|                                     .frame(height: 1) | ||||
|                                     .frame(maxWidth: .infinity) | ||||
|                                  | ||||
|                                 switch(viewModel.currentTab) { | ||||
|                                 case .introduction: | ||||
|                                     SeriesDetailIntroductionView( | ||||
|                                         width: screenSize().width - 26.7, | ||||
|                                         seriesDetail: seriesDetail | ||||
|                                     ) | ||||
|                                      | ||||
|                                 default: | ||||
|                                     SeriesDetailHomeView( | ||||
|                                         title: seriesDetail.title, | ||||
|                                         seriesId: seriesDetail.seriesId, | ||||
|                                         contentCount: seriesDetail.contentCount, | ||||
|                                         contentList: seriesDetail.contentList | ||||
|                                     ) | ||||
|                                 } | ||||
|                             } | ||||
|                             .padding(.bottom, 10) | ||||
|                             .background(Color.gray11) | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         .onAppear { | ||||
|             viewModel.seriesId = seriesId | ||||
|             viewModel.getSeriesDetail() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     SeriesDetailView(seriesId: 0) | ||||
| } | ||||
| @@ -0,0 +1,151 @@ | ||||
| // | ||||
| //  SeriesDetailViewModel.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 4/29/24. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
| import Combine | ||||
|  | ||||
| final class SeriesDetailViewModel: ObservableObject { | ||||
|     private let repository = SeriesRepository() | ||||
|     private let userRepository = UserRepository() | ||||
|     private var subscription = Set<AnyCancellable>() | ||||
|      | ||||
|     @Published var isLoading = false | ||||
|     @Published var errorMessage = "" | ||||
|     @Published var isShowPopup = false | ||||
|      | ||||
|     @Published var isFollow: Bool = false | ||||
|     @Published var seriesDetail: GetSeriesDetailResponse? = nil | ||||
|      | ||||
|     var seriesId: Int = 0 | ||||
|      | ||||
|     func getSeriesDetail() { | ||||
|         isLoading = true | ||||
|          | ||||
|         repository | ||||
|             .getSeriesDetail(seriesId: seriesId) | ||||
|             .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<GetSeriesDetailResponse>.self, from: responseData) | ||||
|                      | ||||
|                     if let data = decoded.data, decoded.success { | ||||
|                         self.seriesDetail = data | ||||
|                         self.isFollow = data.creator.isFollow | ||||
|                     } else { | ||||
|                         if let message = decoded.message { | ||||
|                             self.errorMessage = message | ||||
|                         } else { | ||||
|                             self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         } | ||||
|                          | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } catch { | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                 } | ||||
|                  | ||||
|                 self.isLoading = false | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func follow(_ creatorId: Int) { | ||||
|         isLoading = true | ||||
|          | ||||
|         userRepository.creatorFollow(creatorId: creatorId) | ||||
|             .sink { result in | ||||
|                 switch result { | ||||
|                 case .finished: | ||||
|                     DEBUG_LOG("finish") | ||||
|                 case .failure(let error): | ||||
|                     ERROR_LOG(error.localizedDescription) | ||||
|                 } | ||||
|             } receiveValue: { [unowned self] 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.isFollow = !self.isFollow | ||||
|                     } else { | ||||
|                         if let message = decoded.message { | ||||
|                             self.errorMessage = message | ||||
|                         } else { | ||||
|                             self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         } | ||||
|                          | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } catch { | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                 } | ||||
|                  | ||||
|                 self.isLoading = false | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     func unFollow(_ creatorId: Int) { | ||||
|         isLoading = true | ||||
|          | ||||
|         userRepository.creatorUnFollow(creatorId: creatorId) | ||||
|             .sink { result in | ||||
|                 switch result { | ||||
|                 case .finished: | ||||
|                     DEBUG_LOG("finish") | ||||
|                 case .failure(let error): | ||||
|                     ERROR_LOG(error.localizedDescription) | ||||
|                 } | ||||
|             } receiveValue: { [unowned self] 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.isFollow = !self.isFollow | ||||
|                     } else { | ||||
|                         if let message = decoded.message { | ||||
|                             self.errorMessage = message | ||||
|                         } else { | ||||
|                             self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                         } | ||||
|                          | ||||
|                         self.isShowPopup = true | ||||
|                     } | ||||
|                 } catch { | ||||
|                     self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." | ||||
|                     self.isShowPopup = true | ||||
|                 } | ||||
|                  | ||||
|                 self.isLoading = false | ||||
|             } | ||||
|             .store(in: &subscription) | ||||
|     } | ||||
|      | ||||
|     enum CurrentTab: String { | ||||
|         case home, introduction | ||||
|     } | ||||
|      | ||||
|     @Published var currentTab: CurrentTab = .home | ||||
| } | ||||
| @@ -10,6 +10,7 @@ import Moya | ||||
|  | ||||
| enum SeriesApi { | ||||
|     case getSeriesList(creatorId: Int, sortType: SeriesListAllViewModel.SeriesSortType, page: Int, size: Int) | ||||
|     case getSeriesDetail(seriesId: Int) | ||||
| } | ||||
|  | ||||
| extension SeriesApi: TargetType { | ||||
| @@ -21,12 +22,15 @@ extension SeriesApi: TargetType { | ||||
|         switch self { | ||||
|         case .getSeriesList: | ||||
|             return "/audio-content/series" | ||||
|              | ||||
|         case .getSeriesDetail(let seriesId): | ||||
|             return "/audio-content/series/\(seriesId)" | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     var method: Moya.Method { | ||||
|         switch self { | ||||
|         case .getSeriesList: | ||||
|         case .getSeriesList, .getSeriesDetail: | ||||
|             return .get | ||||
|         } | ||||
|     } | ||||
| @@ -42,6 +46,9 @@ extension SeriesApi: TargetType { | ||||
|             ] as [String : Any] | ||||
|              | ||||
|             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) | ||||
|              | ||||
|         case .getSeriesDetail: | ||||
|             return .requestPlain | ||||
|         } | ||||
|     } | ||||
|      | ||||
|   | ||||
| @@ -16,4 +16,8 @@ class SeriesRepository { | ||||
|     func getSeriesList(creatorId: Int, sortType: SeriesListAllViewModel.SeriesSortType, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getSeriesList(creatorId: creatorId, sortType: sortType, page: page, size: size)) | ||||
|     } | ||||
|      | ||||
|     func getSeriesDetail(seriesId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getSeriesDetail(seriesId: seriesId)) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -181,6 +181,9 @@ struct ContentView: View { | ||||
|             case .seriesAll(let creatorId): | ||||
|                 SeriesListAllView(creatorId: creatorId) | ||||
|                  | ||||
|             case .seriesDetail(let seriesId): | ||||
|                 SeriesDetailView(seriesId: seriesId) | ||||
|                  | ||||
|             default: | ||||
|                 EmptyView() | ||||
|                     .frame(width: 0, height: 0, alignment: .topLeading) | ||||
|   | ||||
							
								
								
									
										42
									
								
								SodaLive/Sources/UI/Component/SeriesDetailTabView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								SodaLive/Sources/UI/Component/SeriesDetailTabView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| // | ||||
| //  SeriesDetailTabView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 4/30/24. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct SeriesDetailTabView: View { | ||||
|      | ||||
|     let title: String | ||||
|     let width: CGFloat | ||||
|     let isSelected: Bool | ||||
|     let onClick: () -> Void | ||||
|      | ||||
|     var body: some View { | ||||
|         VStack(spacing: 0) { | ||||
|             Text(title) | ||||
|                 .font(.custom(isSelected ? Font.bold.rawValue : Font.medium.rawValue, size: 16.7)) | ||||
|                 .foregroundColor(isSelected ? Color.button : Color.gray77) | ||||
|                 .frame(width: width, height: 50) | ||||
|              | ||||
|             if isSelected { | ||||
|                 Rectangle() | ||||
|                     .foregroundColor(Color.button) | ||||
|                     .frame(width: width, height: 3) | ||||
|             } | ||||
|         } | ||||
|         .contentShape(Rectangle()) | ||||
|         .onTapGesture { onClick() } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     SeriesDetailTabView( | ||||
|         title: "홈", | ||||
|         width: 180, | ||||
|         isSelected: true, | ||||
|         onClick: {} | ||||
|     ) | ||||
| } | ||||
							
								
								
									
										27
									
								
								SodaLive/Sources/UI/Component/SeriesKeywordChipView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								SodaLive/Sources/UI/Component/SeriesKeywordChipView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| // | ||||
| //  SeriesKeywordChipView.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 4/29/24. | ||||
| // | ||||
|  | ||||
| import SwiftUI | ||||
|  | ||||
| struct SeriesKeywordChipView: View { | ||||
|      | ||||
|     let keyword: String | ||||
|      | ||||
|     var body: some View { | ||||
|         Text(keyword) | ||||
|             .font(.custom(Font.medium.rawValue, size: 12)) | ||||
|             .foregroundColor(Color.grayd2) | ||||
|             .padding(.horizontal, 8) | ||||
|             .padding(.vertical, 5.3) | ||||
|             .background(Color.gray22) | ||||
|             .cornerRadius(26.7) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #Preview { | ||||
|     SeriesKeywordChipView(keyword: "#로맨스") | ||||
| } | ||||
| @@ -22,8 +22,10 @@ extension Color { | ||||
|     static let gray55 = Color(hex: "555555") | ||||
|     static let gray77 = Color(hex: "777777") | ||||
|     static let gray90 = Color(hex: "909090") | ||||
|     static let gray97 = Color(hex: "979797") | ||||
|     static let graybb = Color(hex: "bbbbbb") | ||||
|     static let grayd2 = Color(hex: "d2d2d2") | ||||
|     static let grayd8 = Color(hex: "d8d8d8") | ||||
|     static let grayee = Color(hex: "eeeeee") | ||||
|      | ||||
|     static let mainRed = Color(hex: "ff5c49") | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Yu Sung
					Yu Sung