From 93110eff8c42a1dc39fbeb18a6e37ef76d65b138 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 30 Apr 2024 14:58:06 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcshareddata/swiftpm/Package.resolved | 9 + .../Content/SeriesContentListItemView.swift | 140 +++++++++++++ .../Detail/GetSeriesContentListResponse.swift | 24 +++ .../Detail/GetSeriesDetailResponse.swift | 37 ++++ .../Series/Detail/SeriesDetailHomeView.swift | 106 ++++++++++ .../Detail/SeriesDetailIntroductionView.swift | 165 +++++++++++++++ .../Series/Detail/SeriesDetailView.swift | 195 ++++++++++++++++++ .../Series/Detail/SeriesDetailViewModel.swift | 151 ++++++++++++++ .../Sources/Content/Series/SeriesApi.swift | 9 +- .../Content/Series/SeriesRepository.swift | 4 + SodaLive/Sources/ContentView.swift | 3 + .../UI/Component/SeriesDetailTabView.swift | 42 ++++ .../UI/Component/SeriesKeywordChipView.swift | 27 +++ SodaLive/Sources/UI/Theme/Color.swift | 2 + 14 files changed, 913 insertions(+), 1 deletion(-) create mode 100644 SodaLive/Sources/Content/Series/Content/SeriesContentListItemView.swift create mode 100644 SodaLive/Sources/Content/Series/Detail/GetSeriesContentListResponse.swift create mode 100644 SodaLive/Sources/Content/Series/Detail/GetSeriesDetailResponse.swift create mode 100644 SodaLive/Sources/Content/Series/Detail/SeriesDetailHomeView.swift create mode 100644 SodaLive/Sources/Content/Series/Detail/SeriesDetailIntroductionView.swift create mode 100644 SodaLive/Sources/Content/Series/Detail/SeriesDetailView.swift create mode 100644 SodaLive/Sources/Content/Series/Detail/SeriesDetailViewModel.swift create mode 100644 SodaLive/Sources/UI/Component/SeriesDetailTabView.swift create mode 100644 SodaLive/Sources/UI/Component/SeriesKeywordChipView.swift diff --git a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2ad0262..75d35f0 100644 --- a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 diff --git a/SodaLive/Sources/Content/Series/Content/SeriesContentListItemView.swift b/SodaLive/Sources/Content/Series/Content/SeriesContentListItemView.swift new file mode 100644 index 0000000..3651ed2 --- /dev/null +++ b/SodaLive/Sources/Content/Series/Content/SeriesContentListItemView.swift @@ -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 + ) + ) +} diff --git a/SodaLive/Sources/Content/Series/Detail/GetSeriesContentListResponse.swift b/SodaLive/Sources/Content/Series/Detail/GetSeriesContentListResponse.swift new file mode 100644 index 0000000..d5a1e35 --- /dev/null +++ b/SodaLive/Sources/Content/Series/Detail/GetSeriesContentListResponse.swift @@ -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 +} diff --git a/SodaLive/Sources/Content/Series/Detail/GetSeriesDetailResponse.swift b/SodaLive/Sources/Content/Series/Detail/GetSeriesDetailResponse.swift new file mode 100644 index 0000000..8a2f320 --- /dev/null +++ b/SodaLive/Sources/Content/Series/Detail/GetSeriesDetailResponse.swift @@ -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 +} diff --git a/SodaLive/Sources/Content/Series/Detail/SeriesDetailHomeView.swift b/SodaLive/Sources/Content/Series/Detail/SeriesDetailHomeView.swift new file mode 100644 index 0000000..38b1433 --- /dev/null +++ b/SodaLive/Sources/Content/Series/Detail/SeriesDetailHomeView.swift @@ -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.. String { + if minPrice == maxPrice { + if maxPrice == 0 { + return "무료" + } else { + return "\(maxPrice)" + } + } else { + return "\(minPrice == 0 ? "무료" : "\(minPrice)") ~ \(maxPrice)캔" + } + } +} diff --git a/SodaLive/Sources/Content/Series/Detail/SeriesDetailView.swift b/SodaLive/Sources/Content/Series/Detail/SeriesDetailView.swift new file mode 100644 index 0000000..5182e98 --- /dev/null +++ b/SodaLive/Sources/Content/Series/Detail/SeriesDetailView.swift @@ -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..() + + @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.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 +} diff --git a/SodaLive/Sources/Content/Series/SeriesApi.swift b/SodaLive/Sources/Content/Series/SeriesApi.swift index 0979ae2..21d41bb 100644 --- a/SodaLive/Sources/Content/Series/SeriesApi.swift +++ b/SodaLive/Sources/Content/Series/SeriesApi.swift @@ -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 } } diff --git a/SodaLive/Sources/Content/Series/SeriesRepository.swift b/SodaLive/Sources/Content/Series/SeriesRepository.swift index 591351b..f22d801 100644 --- a/SodaLive/Sources/Content/Series/SeriesRepository.swift +++ b/SodaLive/Sources/Content/Series/SeriesRepository.swift @@ -16,4 +16,8 @@ class SeriesRepository { func getSeriesList(creatorId: Int, sortType: SeriesListAllViewModel.SeriesSortType, page: Int, size: Int) -> AnyPublisher { return api.requestPublisher(.getSeriesList(creatorId: creatorId, sortType: sortType, page: page, size: size)) } + + func getSeriesDetail(seriesId: Int) -> AnyPublisher { + return api.requestPublisher(.getSeriesDetail(seriesId: seriesId)) + } } diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 5d95491..908c817 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -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) diff --git a/SodaLive/Sources/UI/Component/SeriesDetailTabView.swift b/SodaLive/Sources/UI/Component/SeriesDetailTabView.swift new file mode 100644 index 0000000..6121ba3 --- /dev/null +++ b/SodaLive/Sources/UI/Component/SeriesDetailTabView.swift @@ -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: {} + ) +} diff --git a/SodaLive/Sources/UI/Component/SeriesKeywordChipView.swift b/SodaLive/Sources/UI/Component/SeriesKeywordChipView.swift new file mode 100644 index 0000000..036bb95 --- /dev/null +++ b/SodaLive/Sources/UI/Component/SeriesKeywordChipView.swift @@ -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: "#로맨스") +} diff --git a/SodaLive/Sources/UI/Theme/Color.swift b/SodaLive/Sources/UI/Theme/Color.swift index 544007c..de2b46b 100644 --- a/SodaLive/Sources/UI/Theme/Color.swift +++ b/SodaLive/Sources/UI/Theme/Color.swift @@ -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")