feat(creator): 시리즈 탭을 추가한다
This commit is contained in:
@@ -222,6 +222,12 @@ struct CreatorChannelView: View {
|
||||
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||
onTapContent: showContentDetail
|
||||
)
|
||||
} else if viewModel.selectedTab == .series {
|
||||
CreatorChannelSeriesTabView(
|
||||
creatorId: creatorId,
|
||||
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||
onTapSeries: showSeriesDetail
|
||||
)
|
||||
} else if viewModel.selectedTab != .home {
|
||||
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
|
||||
} else if let response = viewModel.response {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelSeriesSection: View {
|
||||
let series: [CreatorChannelSeriesResponse]
|
||||
let series: [CreatorChannelHomeSeriesResponse]
|
||||
let onSelectTab: (CreatorChannelTab) -> Void
|
||||
let onTapSeries: (Int) -> Void
|
||||
|
||||
init(
|
||||
series: [CreatorChannelSeriesResponse],
|
||||
series: [CreatorChannelHomeSeriesResponse],
|
||||
onSelectTab: @escaping (CreatorChannelTab) -> Void,
|
||||
onTapSeries: @escaping (Int) -> Void = { _ in }
|
||||
) {
|
||||
@@ -40,7 +40,7 @@ struct CreatorChannelSeriesSection: View {
|
||||
}
|
||||
|
||||
private struct CreatorChannelSeriesCard: View {
|
||||
let series: CreatorChannelSeriesResponse
|
||||
let series: CreatorChannelHomeSeriesResponse
|
||||
let action: () -> Void
|
||||
|
||||
private let baseDeviceWidth: CGFloat = 402
|
||||
@@ -100,7 +100,7 @@ struct CreatorChannelSeriesSection_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CreatorChannelSeriesSection(
|
||||
series: (1...3).map { index in
|
||||
CreatorChannelSeriesResponse(
|
||||
CreatorChannelHomeSeriesResponse(
|
||||
seriesId: index,
|
||||
title: "시리즈 제목",
|
||||
coverImageUrl: "https://picsum.photos/300/420?\(index)",
|
||||
|
||||
@@ -8,7 +8,7 @@ struct CreatorChannelHomeResponse: Decodable {
|
||||
let notices: [CreatorChannelCommunityPostResponse]
|
||||
let schedules: [CreatorChannelScheduleResponse]
|
||||
let audioContents: [CreatorChannelAudioContentResponse]
|
||||
let series: [CreatorChannelSeriesResponse]
|
||||
let series: [CreatorChannelHomeSeriesResponse]
|
||||
let communities: [CreatorChannelCommunityPostResponse]
|
||||
let fanTalk: CreatorChannelFanTalkSummaryResponse
|
||||
let introduce: String
|
||||
@@ -73,7 +73,7 @@ struct CreatorChannelScheduleResponse: Decodable {
|
||||
let targetId: Int
|
||||
}
|
||||
|
||||
struct CreatorChannelSeriesResponse: Decodable, Identifiable {
|
||||
struct CreatorChannelHomeSeriesResponse: Decodable, Identifiable {
|
||||
let seriesId: Int
|
||||
let title: String
|
||||
let coverImageUrl: String
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelSeriesListItem: View {
|
||||
let series: CreatorChannelSeriesResponse
|
||||
let showsOwnershipProgress: Bool
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
series: CreatorChannelSeriesResponse,
|
||||
showsOwnershipProgress: Bool,
|
||||
action: @escaping () -> Void = {}
|
||||
) {
|
||||
self.series = series
|
||||
self.showsOwnershipProgress = showsOwnershipProgress
|
||||
self.action = action
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: SodaSpacing.s14) {
|
||||
thumbnail
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
titleArea
|
||||
|
||||
Spacer(minLength: SodaSpacing.s8)
|
||||
|
||||
if showsOwnershipProgress,
|
||||
let purchasedContentCount = series.purchasedContentCount,
|
||||
let paidContentCount = series.paidContentCount,
|
||||
let purchasedPaidContentRate = series.purchasedPaidContentRate {
|
||||
CreatorChannelSeriesOwnershipRateView(
|
||||
purchasedContentCount: purchasedContentCount,
|
||||
paidContentCount: paidContentCount,
|
||||
purchasedPaidContentRate: purchasedPaidContentRate
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s20)
|
||||
.padding(.vertical, SodaSpacing.s8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var thumbnail: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
DownsampledKFImage(
|
||||
url: series.coverImageUrl.flatMap(URL.init(string:)),
|
||||
size: CGSize(width: 122, height: 172)
|
||||
)
|
||||
.background(Color.gray800)
|
||||
|
||||
if series.isOriginal {
|
||||
originalTag
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
if series.isAdult {
|
||||
CreatorChannelAudioAdultTag()
|
||||
.padding(.top, 6)
|
||||
.padding(.trailing, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 122, height: 172)
|
||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||
}
|
||||
|
||||
private var originalTag: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image("ic_series_original")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 14, height: 14)
|
||||
|
||||
Image("img_new_only")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 14)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.gray900)
|
||||
}
|
||||
|
||||
private var titleArea: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(series.title)
|
||||
.appFont(size: 16, weight: .bold)
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Text(subtitle)
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.foregroundColor(Color.gray500)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
let status = series.isProceeding ? I18n.CreatorChannelSeries.proceeding : I18n.CreatorChannelSeries.completed
|
||||
return "\(series.publishedDaysOfWeek) • \(I18n.CreatorChannelSeries.totalContentCount(series.contentCount)) • \(status)"
|
||||
}
|
||||
}
|
||||
|
||||
struct CreatorChannelSeriesListItem_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CreatorChannelSeriesListItem(
|
||||
series: CreatorChannelSeriesResponse(
|
||||
seriesId: 1,
|
||||
title: "시리즈 이름",
|
||||
coverImageUrl: "https://picsum.photos/300/420",
|
||||
publishedDaysOfWeek: "매주 월",
|
||||
isOriginal: true,
|
||||
isAdult: true,
|
||||
isProceeding: false,
|
||||
contentCount: 45,
|
||||
purchasedContentCount: 12,
|
||||
paidContentCount: 45,
|
||||
purchasedPaidContentRate: 40
|
||||
),
|
||||
showsOwnershipProgress: true
|
||||
)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelSeriesOwnershipRateView: View {
|
||||
let purchasedContentCount: Int
|
||||
let paidContentCount: Int
|
||||
let purchasedPaidContentRate: Int
|
||||
|
||||
private var progress: Double {
|
||||
max(0, min(Double(purchasedPaidContentRate) / 100, 1))
|
||||
}
|
||||
|
||||
init(
|
||||
purchasedContentCount: Int,
|
||||
paidContentCount: Int,
|
||||
purchasedPaidContentRate: Int
|
||||
) {
|
||||
self.purchasedContentCount = purchasedContentCount
|
||||
self.paidContentCount = paidContentCount
|
||||
self.purchasedPaidContentRate = purchasedPaidContentRate
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
|
||||
HStack(alignment: .center, spacing: SodaSpacing.s8) {
|
||||
(
|
||||
Text(purchasedContentCount.comma())
|
||||
.foregroundColor(.white)
|
||||
+ Text("/\(paidContentCount.comma())\(I18n.CreatorChannelSeries.contentCountUnit)")
|
||||
.foregroundColor(Color.gray500)
|
||||
)
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: SodaSpacing.s8)
|
||||
|
||||
Text("\(purchasedPaidContentRate)%")
|
||||
.appFont(size: 14, weight: .medium)
|
||||
.foregroundColor(Color.soda400)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(Color.soda400)
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreatorChannelSeriesOwnershipRateView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CreatorChannelSeriesOwnershipRateView(
|
||||
purchasedContentCount: 12,
|
||||
paidContentCount: 45,
|
||||
purchasedPaidContentRate: 40
|
||||
)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreatorChannelSeriesTabView: View {
|
||||
let creatorId: Int
|
||||
let isOwnCreatorChannel: Bool
|
||||
let onTapSeries: (Int) -> Void
|
||||
|
||||
@StateObject private var viewModel = CreatorChannelSeriesViewModel()
|
||||
@State private var isSortPopupPresented = false
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.background(Color.black)
|
||||
.onAppear {
|
||||
if viewModel.hasLoaded == false {
|
||||
viewModel.fetchFirstPage(creatorId: creatorId)
|
||||
}
|
||||
}
|
||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(spacing: 0) {
|
||||
if isEmptyState {
|
||||
emptyStateView
|
||||
} else {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
VStack(spacing: 0) {
|
||||
CreatorChannelSortBar(
|
||||
selectedSort: viewModel.selectedSort,
|
||||
totalCount: viewModel.response?.seriesCount,
|
||||
onTapSort: {
|
||||
isSortPopupPresented.toggle()
|
||||
}
|
||||
)
|
||||
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(viewModel.series) { series in
|
||||
CreatorChannelSeriesListItem(
|
||||
series: series,
|
||||
showsOwnershipProgress: isOwnCreatorChannel == false
|
||||
) {
|
||||
onTapSeries(series.seriesId)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: series)
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingNextPage {
|
||||
ProgressView()
|
||||
.padding(.vertical, SodaSpacing.s20)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, isOwnCreatorChannel ? 70 : SodaSpacing.s20)
|
||||
}
|
||||
|
||||
if isSortPopupPresented {
|
||||
Color.black
|
||||
.opacity(0.001)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onTapGesture {
|
||||
isSortPopupPresented = false
|
||||
}
|
||||
|
||||
CreatorChannelSortContextPopup(selectedSort: viewModel.selectedSort) { sort in
|
||||
viewModel.selectSort(sort, creatorId: creatorId)
|
||||
isSortPopupPresented = false
|
||||
}
|
||||
.padding(.top, 52)
|
||||
.padding(.trailing, SodaSpacing.s14)
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topTrailing)
|
||||
.onChange(of: viewModel.selectedSort) { _ in
|
||||
isSortPopupPresented = false
|
||||
}
|
||||
.onDisappear {
|
||||
isSortPopupPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isEmptyState: Bool {
|
||||
guard viewModel.hasLoaded, let response = viewModel.response else { return false }
|
||||
return response.seriesCount == 0
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
Text(I18n.CreatorChannelSeries.emptyMessage)
|
||||
.appFont(.body3)
|
||||
.foregroundColor(Color.gray500)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 48)
|
||||
}
|
||||
}
|
||||
|
||||
struct CreatorChannelSeriesTabView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CreatorChannelSeriesTabView(
|
||||
creatorId: 1,
|
||||
isOwnCreatorChannel: false,
|
||||
onTapSeries: { _ in }
|
||||
)
|
||||
.background(Color.black)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
final class CreatorChannelSeriesViewModel: ObservableObject {
|
||||
private let repository = CreatorChannelSeriesRepository()
|
||||
private var subscription = Set<AnyCancellable>()
|
||||
private let pageSize = 20
|
||||
private var latestRequestId = 0
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingNextPage = false
|
||||
@Published var response: CreatorChannelSeriesTabResponse?
|
||||
@Published var series = [CreatorChannelSeriesResponse]()
|
||||
@Published var selectedSort: ContentSort = .latest
|
||||
@Published var page = 0
|
||||
@Published var size = 20
|
||||
@Published var hasNext = false
|
||||
@Published var hasLoaded = false
|
||||
@Published var errorMessage = ""
|
||||
@Published var isShowPopup = false
|
||||
|
||||
func fetchFirstPage(creatorId: Int) {
|
||||
fetchSeries(creatorId: creatorId, page: 0, isNextPage: false)
|
||||
}
|
||||
|
||||
func fetchNextPageIfNeeded(creatorId: Int, currentItem: CreatorChannelSeriesResponse) {
|
||||
guard series.last?.seriesId == currentItem.seriesId else { return }
|
||||
guard hasNext, isLoadingNextPage == false, isLoading == false else { return }
|
||||
|
||||
fetchSeries(creatorId: creatorId, page: page + 1, isNextPage: true)
|
||||
}
|
||||
|
||||
func selectSort(_ sort: ContentSort, creatorId: Int) {
|
||||
guard selectedSort != sort else { return }
|
||||
|
||||
selectedSort = sort
|
||||
fetchFirstPage(creatorId: creatorId)
|
||||
}
|
||||
|
||||
private func fetchSeries(creatorId: Int, page requestPage: Int, isNextPage: Bool) {
|
||||
latestRequestId += 1
|
||||
let requestId = latestRequestId
|
||||
|
||||
if isNextPage {
|
||||
isLoadingNextPage = true
|
||||
} else {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
repository.getSeries(creatorId: creatorId, page: requestPage, size: pageSize, sort: selectedSort)
|
||||
.sink { [weak self] result in
|
||||
guard let self else { return }
|
||||
guard requestId == self.latestRequestId else { return }
|
||||
|
||||
switch result {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.applyFailureState()
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self else { return }
|
||||
guard requestId == self.latestRequestId else { return }
|
||||
|
||||
do {
|
||||
let jsonDecoder = JSONDecoder()
|
||||
let decoded = try jsonDecoder.decode(ApiResponse<CreatorChannelSeriesTabResponse>.self, from: response.data)
|
||||
|
||||
if let data = decoded.data, decoded.success {
|
||||
self.applySuccessState(data, isNextPage: isNextPage)
|
||||
} else {
|
||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
} catch {
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self.errorMessage = I18n.Common.commonError
|
||||
self.isShowPopup = true
|
||||
}
|
||||
|
||||
self.hasLoaded = true
|
||||
self.isLoading = false
|
||||
self.isLoadingNextPage = false
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func applySuccessState(_ data: CreatorChannelSeriesTabResponse, isNextPage: Bool) {
|
||||
response = data
|
||||
selectedSort = data.sort
|
||||
page = data.page
|
||||
size = data.size
|
||||
hasNext = data.hasNext
|
||||
|
||||
if isNextPage {
|
||||
series.append(contentsOf: data.series)
|
||||
} else {
|
||||
series = data.series
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFailureState() {
|
||||
errorMessage = I18n.Common.commonError
|
||||
isShowPopup = true
|
||||
hasLoaded = true
|
||||
isLoading = false
|
||||
isLoadingNextPage = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
struct CreatorChannelSeriesTabResponse: Decodable {
|
||||
let seriesCount: Int
|
||||
let series: [CreatorChannelSeriesResponse]
|
||||
let sort: ContentSort
|
||||
let page: Int
|
||||
let size: Int
|
||||
let hasNext: Bool
|
||||
}
|
||||
|
||||
struct CreatorChannelSeriesResponse: Decodable, Identifiable {
|
||||
let seriesId: Int
|
||||
let title: String
|
||||
let coverImageUrl: String?
|
||||
let publishedDaysOfWeek: String
|
||||
let isOriginal: Bool
|
||||
let isAdult: Bool
|
||||
let isProceeding: Bool
|
||||
let contentCount: Int
|
||||
let purchasedContentCount: Int?
|
||||
let paidContentCount: Int?
|
||||
let purchasedPaidContentRate: Int?
|
||||
|
||||
var id: Int { seriesId }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
import Moya
|
||||
|
||||
enum CreatorChannelSeriesApi {
|
||||
case getSeries(creatorId: Int, page: Int, size: Int, sort: ContentSort)
|
||||
}
|
||||
|
||||
extension CreatorChannelSeriesApi: TargetType {
|
||||
var baseURL: URL {
|
||||
return URL(string: BASE_URL)!
|
||||
}
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
case .getSeries(let creatorId, _, _, _):
|
||||
return "/api/v2/creator-channels/\(creatorId)/series"
|
||||
}
|
||||
}
|
||||
|
||||
var method: Moya.Method {
|
||||
switch self {
|
||||
case .getSeries:
|
||||
return .get
|
||||
}
|
||||
}
|
||||
|
||||
var task: Moya.Task {
|
||||
switch self {
|
||||
case .getSeries(_, let page, let size, let sort):
|
||||
return .requestParameters(
|
||||
parameters: [
|
||||
"page": page,
|
||||
"size": size,
|
||||
"sort": sort.rawValue
|
||||
],
|
||||
encoding: URLEncoding.queryString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var headers: [String: String]? {
|
||||
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
import CombineMoya
|
||||
import Moya
|
||||
|
||||
final class CreatorChannelSeriesRepository {
|
||||
private let api = MoyaProvider<CreatorChannelSeriesApi>()
|
||||
|
||||
func getSeries(
|
||||
creatorId: Int,
|
||||
page: Int,
|
||||
size: Int,
|
||||
sort: ContentSort
|
||||
) -> AnyPublisher<Response, MoyaError> {
|
||||
return api.requestPublisher(
|
||||
.getSeries(
|
||||
creatorId: creatorId,
|
||||
page: page,
|
||||
size: size,
|
||||
sort: sort
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user