feat(creator): 시리즈 탭을 추가한다

This commit is contained in:
Yu Sung
2026-07-04 18:50:24 +09:00
parent 0dd111f5d6
commit 8c71b6883b
14 changed files with 1022 additions and 6 deletions

View File

@@ -3199,6 +3199,23 @@ If you block this user, the following features will be restricted.
}
}
enum CreatorChannelSeries {
static var contentCountUnit: String { pick(ko: "", en: " episodes", ja: "") }
static var proceeding: String { pick(ko: "연재", en: "Ongoing", ja: "連載") }
static var completed: String { pick(ko: "완결", en: "Completed", ja: "完結") }
static var emptyMessage: String {
pick(
ko: "크리에이터가 시리즈를 준비 중입니다.\n기대해 주세요!",
en: "The creator is preparing series.\nPlease stay tuned!",
ja: "クリエイターがシリーズを準備中です。\nお楽しみに!"
)
}
static func totalContentCount(_ count: Int) -> String {
pick(ko: "\(count)", en: "Total \(count) episodes", ja: "\(count)")
}
}
enum CreatorChannelHome {
static var currentLive: String { pick(ko: "현재 라이브", en: "Live now", ja: "現在ライブ") }
static var latestAudio: String { pick(ko: "최신 오디오 콘텐츠", en: "Latest audio", ja: "最新オーディオ") }

View File

@@ -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 {

View File

@@ -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)",

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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 }
}

View File

@@ -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))"]
}
}

View File

@@ -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
)
)
}
}