feat(creator): 시리즈 탭을 추가한다
This commit is contained in:
@@ -1809,6 +1809,27 @@
|
||||
CC0303052FF0300000A5BBA3 /* CreatorChannelAudioOwnershipRateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0300052FF0300000A5BBA3 /* CreatorChannelAudioOwnershipRateView.swift */; };
|
||||
CC0303062FF0300000A5BBA3 /* CreatorChannelAudioUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0300062FF0300000A5BBA3 /* CreatorChannelAudioUploadButton.swift */; };
|
||||
CC0303072FF0300000A5BBA3 /* CreatorChannelAudioTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0300072FF0300000A5BBA3 /* CreatorChannelAudioTabView.swift */; };
|
||||
CC0401012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift */; };
|
||||
CC0401022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift */; };
|
||||
CC0401032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift */; };
|
||||
CC0401042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift */; };
|
||||
CC0401052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift */; };
|
||||
CC0401062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift */; };
|
||||
CC0401072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift */; };
|
||||
CC0402012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift */; };
|
||||
CC0402022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift */; };
|
||||
CC0402032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift */; };
|
||||
CC0402042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift */; };
|
||||
CC0402052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift */; };
|
||||
CC0402062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift */; };
|
||||
CC0402072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift */; };
|
||||
CC0403012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift */; };
|
||||
CC0403022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift */; };
|
||||
CC0403032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift */; };
|
||||
CC0403042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift */; };
|
||||
CC0403052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift */; };
|
||||
CC0403062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift */; };
|
||||
CC0403072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0400072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift */; };
|
||||
D1C65856889B16DFDB69A004 /* PushNotificationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FDDF6BBD0192819B1F5B3FE /* PushNotificationRepository.swift */; };
|
||||
D2A268C7346E84E6B455E25A /* Pods_SodaLive_dev.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F4E5BB7961B7E6BB68C3379 /* Pods_SodaLive_dev.framework */; };
|
||||
DD202E48D61F1CE8108AD164 /* LineSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 6DBA0C325ACEC3FCDB22490D /* LineSDK */; };
|
||||
@@ -2685,6 +2706,13 @@
|
||||
CC0300052FF0300000A5BBA3 /* CreatorChannelAudioOwnershipRateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelAudioOwnershipRateView.swift; sourceTree = "<group>"; };
|
||||
CC0300062FF0300000A5BBA3 /* CreatorChannelAudioUploadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelAudioUploadButton.swift; sourceTree = "<group>"; };
|
||||
CC0300072FF0300000A5BBA3 /* CreatorChannelAudioTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelAudioTabView.swift; sourceTree = "<group>"; };
|
||||
CC0400012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSeriesTabResponse.swift; sourceTree = "<group>"; };
|
||||
CC0400022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSeriesApi.swift; sourceTree = "<group>"; };
|
||||
CC0400032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSeriesRepository.swift; sourceTree = "<group>"; };
|
||||
CC0400042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSeriesViewModel.swift; sourceTree = "<group>"; };
|
||||
CC0400052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSeriesOwnershipRateView.swift; sourceTree = "<group>"; };
|
||||
CC0400062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSeriesListItem.swift; sourceTree = "<group>"; };
|
||||
CC0400072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSeriesTabView.swift; sourceTree = "<group>"; };
|
||||
CC73FFC54CB1D988053059D1 /* LanguageEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageEnvironment.swift; sourceTree = "<group>"; };
|
||||
CE82912F2F111593BE42E33C /* CommunityPostCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommunityPostCard.swift; sourceTree = "<group>"; };
|
||||
D03CBA8005C81F7C31F59A89 /* Pods-SodaLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SodaLive.release.xcconfig"; path = "Target Support Files/Pods-SodaLive/Pods-SodaLive.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -5290,6 +5318,7 @@
|
||||
CC0100232FF0100000A5BBA3 /* Home */,
|
||||
CC0100332FF0100000A5BBA3 /* Live */,
|
||||
CC0300112FF0300000A5BBA3 /* Audio */,
|
||||
CC0400112FF0400000A5BBA3 /* Series */,
|
||||
CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */,
|
||||
CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */,
|
||||
);
|
||||
@@ -5394,6 +5423,44 @@
|
||||
path = Audio;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CC0400082FF0400000A5BBA3 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CC0400012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CC0400092FF0400000A5BBA3 /* Repository */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CC0400022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift */,
|
||||
CC0400032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift */,
|
||||
);
|
||||
path = Repository;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CC0400102FF0400000A5BBA3 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CC0400052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift */,
|
||||
CC0400062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CC0400112FF0400000A5BBA3 /* Series */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CC0400082FF0400000A5BBA3 /* Models */,
|
||||
CC0400092FF0400000A5BBA3 /* Repository */,
|
||||
CC0400102FF0400000A5BBA3 /* Components */,
|
||||
CC0400042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift */,
|
||||
CC0400072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift */,
|
||||
);
|
||||
path = Series;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D37878F5929361B43B1E3C5C /* Card */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -5861,6 +5928,13 @@
|
||||
CC0301052FF0300000A5BBA3 /* CreatorChannelAudioOwnershipRateView.swift in Sources */,
|
||||
CC0301062FF0300000A5BBA3 /* CreatorChannelAudioUploadButton.swift in Sources */,
|
||||
CC0301072FF0300000A5BBA3 /* CreatorChannelAudioTabView.swift in Sources */,
|
||||
CC0401012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift in Sources */,
|
||||
CC0401022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift in Sources */,
|
||||
CC0401032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift in Sources */,
|
||||
CC0401042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift in Sources */,
|
||||
CC0401052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift in Sources */,
|
||||
CC0401062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift in Sources */,
|
||||
CC0401072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift in Sources */,
|
||||
409E35082B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */,
|
||||
40DDBD772B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */,
|
||||
400EA7742A89C42C004992CF /* StartLiveRequest.swift in Sources */,
|
||||
@@ -6690,6 +6764,13 @@
|
||||
CC0302052FF0300000A5BBA3 /* CreatorChannelAudioOwnershipRateView.swift in Sources */,
|
||||
CC0302062FF0300000A5BBA3 /* CreatorChannelAudioUploadButton.swift in Sources */,
|
||||
CC0302072FF0300000A5BBA3 /* CreatorChannelAudioTabView.swift in Sources */,
|
||||
CC0402012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift in Sources */,
|
||||
CC0402022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift in Sources */,
|
||||
CC0402032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift in Sources */,
|
||||
CC0402042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift in Sources */,
|
||||
CC0402052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift in Sources */,
|
||||
CC0402062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift in Sources */,
|
||||
CC0402072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift in Sources */,
|
||||
401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
|
||||
401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
|
||||
401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
|
||||
@@ -7535,6 +7616,13 @@
|
||||
CC0303052FF0300000A5BBA3 /* CreatorChannelAudioOwnershipRateView.swift in Sources */,
|
||||
CC0303062FF0300000A5BBA3 /* CreatorChannelAudioUploadButton.swift in Sources */,
|
||||
CC0303072FF0300000A5BBA3 /* CreatorChannelAudioTabView.swift in Sources */,
|
||||
CC0403012FF0400000A5BBA3 /* CreatorChannelSeriesTabResponse.swift in Sources */,
|
||||
CC0403022FF0400000A5BBA3 /* CreatorChannelSeriesApi.swift in Sources */,
|
||||
CC0403032FF0400000A5BBA3 /* CreatorChannelSeriesRepository.swift in Sources */,
|
||||
CC0403042FF0400000A5BBA3 /* CreatorChannelSeriesViewModel.swift in Sources */,
|
||||
CC0403052FF0400000A5BBA3 /* CreatorChannelSeriesOwnershipRateView.swift in Sources */,
|
||||
CC0403062FF0400000A5BBA3 /* CreatorChannelSeriesListItem.swift in Sources */,
|
||||
CC0403072FF0400000A5BBA3 /* CreatorChannelSeriesTabView.swift in Sources */,
|
||||
401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
|
||||
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
|
||||
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
|
||||
|
||||
@@ -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: "最新オーディオ") }
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
222
docs/20260704_크리에이터_채널_시리즈_탭/plan-task.md
Normal file
222
docs/20260704_크리에이터_채널_시리즈_탭/plan-task.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 크리에이터 채널 시리즈 탭 구현 계획
|
||||
|
||||
## 기준 문서
|
||||
|
||||
- PRD: `docs/20260704_크리에이터_채널_시리즈_탭/prd.md`
|
||||
- 기존 오디오 탭 PRD: `docs/20260704_크리에이터_채널_오디오_탭/prd.md`
|
||||
- 기존 오디오 탭 구현 계획: `docs/20260704_크리에이터_채널_오디오_탭/plan-task.md`
|
||||
- 코드 스타일: `docs/agent-guides/code-style.md`
|
||||
- 빌드/검증: `docs/agent-guides/build-test-verification.md`
|
||||
- Figma 전체 화면: `290:9031`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9031&m=dev`
|
||||
- Figma 시리즈 아이템: `290:9036`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9036&m=dev`
|
||||
- Figma 시리즈 콘텐츠 소장률: `290:9038`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9038&m=dev`
|
||||
|
||||
## 브레인스토밍 결과
|
||||
|
||||
- 선택지 A: 홈 탭 `CreatorChannelSeriesResponse`를 확장한다.
|
||||
- 장점: 타입 수가 적다.
|
||||
- 단점: 홈 탭 카드 데이터와 시리즈 탭 리스트 데이터의 필드가 달라 모델 책임이 섞인다.
|
||||
- 선택지 B: 오디오 콘텐츠 list item을 변형해 시리즈 item으로 사용한다.
|
||||
- 장점: 레이아웃 작업이 일부 줄어든다.
|
||||
- 단점: 오디오 콘텐츠의 가격/소장/재생 상태와 시리즈의 요일/진행/콘텐츠 수 표시 규칙이 달라 조건 분기가 커진다.
|
||||
- 선택지 C: `Series/**` 하위에 시리즈 탭 전용 모델, ViewModel, View, item을 둔다.
|
||||
- 장점: API 응답과 UI 책임이 명확하고 오디오/홈 탭 영향이 작다.
|
||||
- 결정: 선택지 C를 사용한다.
|
||||
|
||||
## 구현 원칙
|
||||
|
||||
- 기존 `CreatorChannelView`의 공통 shell, header, sticky tab-bar, title bar 동작은 유지한다.
|
||||
- `CreatorChannelTab.series` 콘텐츠만 신규 `CreatorChannelSeriesTabView`로 교체한다.
|
||||
- `ContentSort`, `CreatorChannelSortBar`, 정렬 context popup을 재사용한다.
|
||||
- 정렬 값은 오디오 탭과 동일한 `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`만 사용한다.
|
||||
- 홈 탭의 시리즈 카드/모델은 유지하되, 홈 탭 모델명은 `CreatorChannelHomeSeriesResponse`로 변경한다.
|
||||
- 시리즈 탭 API 응답은 별도 모델로 두고, item 모델명은 API 명세와 동일하게 `CreatorChannelSeriesResponse`로 둔다.
|
||||
- 시리즈 아이템의 가장 우측 버튼은 구현하지 않는다.
|
||||
- 숨긴 버튼 왼쪽 UI는 썸네일 overlay asset이 아니라 아이템 가로 레이아웃에서 우측 버튼 바로 왼쪽에 있는 정보 영역을 의미한다.
|
||||
- 해당 정보 영역은 play button 영역까지 차지하도록 유연한 레이아웃을 사용한다.
|
||||
- 썸네일 overlay는 `isOriginal == true`일 때 `ic_series_original` + `img_new_only`를 조합한 `ONLY` 표시만 담당한다.
|
||||
- Figma `290:9036` 기준 아이템 우측 성인 태그 위치는 `top: 6`, `right: 6`이다.
|
||||
- 아이템 우측 성인 태그는 `CreatorChannelAudioAdultTag().padding(.top, 6).padding(.trailing, 6)` 형태로 재사용한다.
|
||||
- 이미지 크기처럼 제한이 필요한 경우가 아니라면 고정 크기 상수를 추가하지 않는다.
|
||||
- 본인 채널에서는 소장률을 숨기고 상단 info만 표시한다.
|
||||
|
||||
## 주요 대상 파일
|
||||
|
||||
### 생성
|
||||
- `SodaLive/Sources/V2/CreatorChannel/Series/Models/CreatorChannelSeriesTabResponse.swift`
|
||||
- `SodaLive/Sources/V2/CreatorChannel/Series/Repository/CreatorChannelSeriesApi.swift`
|
||||
- `SodaLive/Sources/V2/CreatorChannel/Series/Repository/CreatorChannelSeriesRepository.swift`
|
||||
- `SodaLive/Sources/V2/CreatorChannel/Series/CreatorChannelSeriesViewModel.swift`
|
||||
- `SodaLive/Sources/V2/CreatorChannel/Series/CreatorChannelSeriesTabView.swift`
|
||||
- `SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesListItem.swift`
|
||||
- `SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesOwnershipRateView.swift`
|
||||
|
||||
### 수정
|
||||
- `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||
- `SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift`
|
||||
- `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelSeriesSection.swift`
|
||||
- `SodaLive/Sources/I18n/I18n.swift`
|
||||
- 필요 시 `SodaLive.xcodeproj/project.pbxproj`
|
||||
|
||||
## TASK 체크리스트
|
||||
|
||||
### Phase 1: 문서와 정렬 요구사항 확정
|
||||
|
||||
- [x] **Task 1.1: PRD와 계획 문서 생성**
|
||||
- 대상 파일:
|
||||
- 생성: `docs/20260704_크리에이터_채널_시리즈_탭/prd.md`
|
||||
- 생성: `docs/20260704_크리에이터_채널_시리즈_탭/plan-task.md`
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "290:9031|290:9036|290:9038|CreatorChannelSeriesTabResponse|CreatorChannelSeriesResponse|CreatorChannelHomeSeriesResponse|PRICE_LOW" docs/20260704_크리에이터_채널_시리즈_탭`
|
||||
- 기대 결과: Figma URL, 응답 모델, 홈 탭 모델명 변경, 시리즈 탭 item 모델명, 정렬 범위가 문서에 기록된다.
|
||||
|
||||
- [x] **Task 1.2: 브레인스토밍 기반 설계 확장**
|
||||
- 대상 파일:
|
||||
- 수정: `docs/20260704_크리에이터_채널_시리즈_탭/prd.md`
|
||||
- 수정: `docs/20260704_크리에이터_채널_시리즈_탭/plan-task.md`
|
||||
- 작업 내용:
|
||||
- 홈 탭 모델 확장, 오디오 item 변형, 시리즈 전용 모듈 생성 선택지를 비교한다.
|
||||
- 시리즈 전용 모듈 생성 결정을 문서에 기록한다.
|
||||
- `OWNED`는 필요 없다는 사용자 결정을 반영해 정렬 범위를 4개 값으로 고정한다.
|
||||
- 홈 탭 기존 모델은 `CreatorChannelHomeSeriesResponse`, 시리즈 탭 신규 모델은 `CreatorChannelSeriesResponse`로 분리한다.
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "브레인스토밍|선택지 C|OWNED|필요 없|LATEST|POPULAR|PRICE_HIGH|PRICE_LOW" docs/20260704_크리에이터_채널_시리즈_탭`
|
||||
- 기대 결과: 선택지 검토와 `OWNED` 제외 결정이 문서에 기록된다.
|
||||
|
||||
### Phase 2: API 모델, API, Repository
|
||||
|
||||
- [x] **Task 2.1: 시리즈 탭 Response model 생성**
|
||||
- 대상 파일:
|
||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Series/Models/CreatorChannelSeriesTabResponse.swift`
|
||||
- 작업 내용:
|
||||
- `CreatorChannelSeriesTabResponse`와 `CreatorChannelSeriesResponse`를 `Decodable`로 생성한다.
|
||||
- `CreatorChannelSeriesTabResponse.series`는 `[CreatorChannelSeriesResponse]`로 선언한다.
|
||||
- Kotlin `Long` 필드인 `seriesId`는 Swift `Int`로 선언한다.
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "struct CreatorChannelSeriesTabResponse|seriesCount|series: \\[CreatorChannelSeriesResponse\\]|struct CreatorChannelSeriesResponse|publishedDaysOfWeek|isProceeding|purchasedPaidContentRate" SodaLive/Sources/V2/CreatorChannel/Series/Models/CreatorChannelSeriesTabResponse.swift`
|
||||
|
||||
- [x] **Task 2.2: 홈 탭 시리즈 모델명 변경**
|
||||
- 대상 파일:
|
||||
- 수정: `SodaLive/Sources/V2/CreatorChannel/Home/Models/CreatorChannelHomeResponse.swift`
|
||||
- 수정: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelSeriesSection.swift`
|
||||
- 확인/수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||
- 작업 내용:
|
||||
- 기존 홈 탭 `CreatorChannelSeriesResponse` 타입명을 `CreatorChannelHomeSeriesResponse`로 변경한다.
|
||||
- `CreatorChannelHomeResponse.series`는 `[CreatorChannelHomeSeriesResponse]`로 변경한다.
|
||||
- 홈 탭 시리즈 섹션과 preview의 타입 참조를 함께 변경한다.
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "CreatorChannelHomeSeriesResponse|CreatorChannelSeriesResponse" SodaLive/Sources/V2/CreatorChannel/Home SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||
- 기대 결과: 홈 탭에서는 `CreatorChannelHomeSeriesResponse`를 사용하고, 신규 시리즈 탭 외부의 기존 홈 탭 타입명 충돌이 사라진다.
|
||||
|
||||
- [x] **Task 2.3: 시리즈 탭 API endpoint 생성**
|
||||
- 대상 파일:
|
||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Series/Repository/CreatorChannelSeriesApi.swift`
|
||||
- 작업 내용:
|
||||
- `CreatorChannelSeriesApi.getSeries(creatorId:page:size:sort:)`를 만든다.
|
||||
- path는 `/api/v2/creator-channels/{creatorId}/series`로 구성한다.
|
||||
- query는 `sort`, `page`, `size`를 포함한다.
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "CreatorChannelSeriesApi|getSeries|/api/v2/creator-channels/|/series|sort|page|size|URLEncoding.queryString" SodaLive/Sources/V2/CreatorChannel/Series/Repository/CreatorChannelSeriesApi.swift`
|
||||
|
||||
- [x] **Task 2.4: 시리즈 탭 Repository 생성**
|
||||
- 대상 파일:
|
||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Series/Repository/CreatorChannelSeriesRepository.swift`
|
||||
- 작업 내용:
|
||||
- `MoyaProvider<CreatorChannelSeriesApi>`를 사용하는 `getSeries(...)` publisher를 제공한다.
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "CreatorChannelSeriesRepository|MoyaProvider<CreatorChannelSeriesApi>|AnyPublisher<Response, MoyaError>|getSeries" SodaLive/Sources/V2/CreatorChannel/Series/Repository/CreatorChannelSeriesRepository.swift`
|
||||
|
||||
### Phase 3: ViewModel
|
||||
|
||||
- [x] **Task 3.1: 시리즈 탭 ViewModel 생성**
|
||||
- 대상 파일:
|
||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Series/CreatorChannelSeriesViewModel.swift`
|
||||
- 작업 내용:
|
||||
- 상태는 loading, response, series list, selectedSort, page, size, hasNext, hasLoaded, error toast를 둔다.
|
||||
- 최초 진입 시 `page=0`, `size=20`, `sort=.latest`로 조회한다.
|
||||
- 정렬 변경 시 첫 페이지부터 다시 조회한다.
|
||||
- 다음 페이지 조회 시 기존 목록에 append한다.
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "CreatorChannelSeriesViewModel|selectedSort|fetchFirstPage|fetchNextPageIfNeeded|selectSort|page|hasNext|series" SodaLive/Sources/V2/CreatorChannel/Series/CreatorChannelSeriesViewModel.swift`
|
||||
|
||||
### Phase 4: Figma 섹션별 UI
|
||||
|
||||
- [x] **Task 4.1: 시리즈 콘텐츠 소장률 UI 구현**
|
||||
- 대상 파일:
|
||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesOwnershipRateView.swift`
|
||||
- 작업 내용:
|
||||
- `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`를 표시한다.
|
||||
- progress 값은 `purchasedPaidContentRate / 100`을 0...1 범위로 clamp한다.
|
||||
- 본인 채널 여부에 따른 progress/count 숨김은 상위 view에서 제어한다.
|
||||
- 본인 채널에서 재사용할 수 있도록 상단 info 영역과 progress/count 영역을 분리 가능한 구조로 만든다.
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "CreatorChannelSeriesOwnershipRateView|purchasedContentCount|paidContentCount|purchasedPaidContentRate|ProgressView|progress" SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesOwnershipRateView.swift`
|
||||
|
||||
- [x] **Task 4.2: 시리즈 리스트 item 구현**
|
||||
- 대상 파일:
|
||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesListItem.swift`
|
||||
- 작업 내용:
|
||||
- Figma `290:9036` 기준으로 title, published days, original/adult/proceeding 상태, content count를 표시한다.
|
||||
- `isOriginal == true`이면 썸네일 overlay에 `ic_series_original`과 `img_new_only`를 조합한 `ONLY` 표시를 노출한다.
|
||||
- `isAdult == true`이면 Figma `top: 6`, `right: 6` 기준에 맞춰 아이템 우측에 `CreatorChannelAudioAdultTag().padding(.top, 6).padding(.trailing, 6)`를 표시한다.
|
||||
- 가장 우측 버튼은 표시하지 않는다.
|
||||
- 버튼 왼쪽 UI는 썸네일 overlay asset이 아니라 우측 버튼 바로 왼쪽의 정보 영역으로 정의한다.
|
||||
- 해당 정보 영역은 play button 영역까지 확장되도록 `maxWidth: .infinity`와 `Spacer(minLength:)` 기반으로 구성한다.
|
||||
- 이미지 또는 아이콘 크기처럼 필요한 항목만 고정 크기를 사용한다.
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "CreatorChannelSeriesListItem|publishedDaysOfWeek|isOriginal|isAdult|isProceeding|contentCount|maxWidth: \\.infinity" SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesListItem.swift`
|
||||
|
||||
- [x] **Task 4.3: 시리즈 탭 화면 구현**
|
||||
- 대상 파일:
|
||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Series/CreatorChannelSeriesTabView.swift`
|
||||
- 수정: `SodaLive/Sources/I18n/I18n.swift`
|
||||
- 작업 내용:
|
||||
- `CreatorChannelSortBar`와 `CreatorChannelSortContextPopup`을 오디오 탭과 동일하게 배치한다.
|
||||
- `seriesCount`를 sort-bar에 전달한다.
|
||||
- 본인 채널이 아니고 소장률 값이 모두 있을 때만 `CreatorChannelSeriesOwnershipRateView`를 표시한다.
|
||||
- 본인 채널이면 소장률 progress/count를 숨기고 상단 info만 표시한다.
|
||||
- `seriesCount == 0`이면 empty UI를 표시한다.
|
||||
- 리스트 하단에서 `fetchNextPageIfNeeded`를 호출한다.
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "CreatorChannelSeriesTabView|CreatorChannelSortBar|CreatorChannelSortContextPopup|seriesCount|CreatorChannelSeriesOwnershipRateView|CreatorChannelSeriesListItem|fetchNextPageIfNeeded" SodaLive/Sources/V2/CreatorChannel/Series/CreatorChannelSeriesTabView.swift SodaLive/Sources/I18n/I18n.swift`
|
||||
|
||||
### Phase 5: CreatorChannelView 연결과 검증
|
||||
|
||||
- [x] **Task 5.1: 시리즈 탭 콘텐츠 연결**
|
||||
- 대상 파일:
|
||||
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||
- 작업 내용:
|
||||
- `CreatorChannelTab.series` placeholder를 `CreatorChannelSeriesTabView`로 교체한다.
|
||||
- `creatorId`, 본인 채널 여부, `showSeriesDetail(_:)` 액션을 전달한다.
|
||||
- 검증 기준:
|
||||
- 실행 명령: `rg "CreatorChannelSeriesTabView|selectedTab == \\.series|showSeriesDetail" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||
|
||||
- [x] **Task 5.2: 프로젝트 파일 등록과 정적 검증**
|
||||
- 대상 파일:
|
||||
- 확인/수정: `SodaLive.xcodeproj/project.pbxproj`
|
||||
- 검증 기준:
|
||||
- 실행 명령: `git diff --check -- docs/20260704_크리에이터_채널_시리즈_탭 SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/I18n/I18n.swift SodaLive.xcodeproj/project.pbxproj`
|
||||
- 기대 결과: 공백 오류 없이 성공한다.
|
||||
- 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj`
|
||||
- 기대 결과: `OK`를 출력한다.
|
||||
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
|
||||
- 기대 결과: 빌드 성공.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 2026-07-04: 사용자 제공 API 명세, 정렬 방식, Figma 링크, 본인 채널 소장률 표시 조건을 기준으로 PRD와 계획 문서를 생성했다.
|
||||
- 2026-07-04: `sed`와 `rg`로 기존 오디오 탭 문서, `CreatorChannelAudioTabResponse`, `ContentSort`, `CreatorChannelSortBar`, `CreatorChannelSortContextPopup`, 홈 탭 `CreatorChannelSeriesResponse`, `showSeriesDetail(_:)` 경로를 확인했다.
|
||||
- 2026-07-04: 현재 Swift `ContentSort`가 오디오 탭 정렬 4개(`LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`)를 제공하는 것을 확인했다.
|
||||
- 2026-07-04: 사용자 확인에 따라 `OWNED`는 시리즈 탭 정렬 범위에서 제외했다.
|
||||
- 2026-07-04: `superpowers:brainstorming` 관점으로 홈 탭 모델 확장, 오디오 item 변형, 시리즈 전용 모듈 생성 선택지를 비교했고, 시리즈 전용 모듈 방식을 문서에 반영했다.
|
||||
- 2026-07-04: "버튼 왼쪽 UI"는 썸네일 overlay asset이 아니라 시리즈 item 우측 버튼 바로 왼쪽의 정보 영역을 의미하는 것으로 명확히 정리했다.
|
||||
- 2026-07-04: 썸네일 overlay는 `isOriginal == true`일 때 `ic_series_original` + `img_new_only`로 표시하고, 우측 성인 태그는 `CreatorChannelAudioAdultTag().padding(.top, 6).padding(.trailing, 6)`로 재사용하도록 반영했다.
|
||||
- 2026-07-04: Figma `290:9036`의 design context에서 성인 태그 위치가 `top: 6`, `right: 6`임을 확인해 padding 값을 유지했다.
|
||||
- 2026-07-04: 기존 홈 탭 시리즈 모델은 `CreatorChannelHomeSeriesResponse`로 변경하고, 신규 시리즈 탭 API 모델은 명세와 동일한 `CreatorChannelSeriesResponse`를 사용하도록 결정했다.
|
||||
- 2026-07-04: `CreatorChannelSeriesTabResponse`, `CreatorChannelSeriesApi`, `CreatorChannelSeriesRepository`, `CreatorChannelSeriesViewModel`을 추가하고 홈 탭 시리즈 모델명을 `CreatorChannelHomeSeriesResponse`로 변경했다.
|
||||
- 2026-07-04: `CreatorChannelSeriesOwnershipRateView`, `CreatorChannelSeriesListItem`, `CreatorChannelSeriesTabView`를 추가하고 `CreatorChannelView`의 `.series` 탭을 신규 탭 화면에 연결했다.
|
||||
- 2026-07-04: 신규 Series Swift 파일을 `SodaLive.xcodeproj/project.pbxproj`의 CreatorChannel group과 세 Sources build phase에 등록했다.
|
||||
- 2026-07-04: 계획 문서의 `rg` 검증, `git diff --check`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`를 실행해 통과를 확인했다.
|
||||
- 2026-07-04: 현재 `SodaLive.xcodeproj/project.pbxproj`와 `docs/agent-guides/build-test-verification.md` 기준으로 XCTest 번들 타깃이 확인되지 않아 RED/GREEN 단위 테스트는 수행하지 못했고, 정적 검증과 실제 앱 빌드로 대체했다.
|
||||
- 2026-07-04: 리뷰 게이트에서 지적된 썸네일 컨텍스트를 반영해 시리즈 item 모델에 optional `coverImageUrl`을 추가하고, 값이 있을 때 `DownsampledKFImage`로 표시하도록 보강했다.
|
||||
- 2026-07-04: 본인 채널에서는 소장률 progress/count 영역을 렌더링하지 않고 시리즈 제목/요일/상태 정보만 남도록 수정했으며, QA/컨텍스트 재리뷰에서 blocking 이슈 해소를 확인했다.
|
||||
169
docs/20260704_크리에이터_채널_시리즈_탭/prd.md
Normal file
169
docs/20260704_크리에이터_채널_시리즈_탭/prd.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# PRD: 크리에이터 채널 시리즈 탭
|
||||
|
||||
## 1. Overview
|
||||
크리에이터 채널 공통 shell의 `시리즈` 탭에서 크리에이터의 시리즈 목록, 정렬, 시리즈 콘텐츠 소장률 정보를 제공한다. 상단 title bar, header, sticky tab-bar는 기존 크리에이터 채널 홈/오디오 탭 구현을 재사용하고, tab-bar 아래 콘텐츠만 시리즈 탭 전용 API 응답으로 구성한다.
|
||||
|
||||
API는 `GET /api/v2/creator-channels/{creatorId}/series`를 사용한다. 기본 query는 `page=0`, `size=20`, `sort=LATEST`이다.
|
||||
|
||||
브레인스토밍 결과, 홈 탭 시리즈 모델을 확장하거나 오디오 콘텐츠 item을 변형하기보다 시리즈 탭 전용 `Series/**` 모듈을 추가하는 방식을 선택한다. 홈 탭 시리즈 모델은 커버 이미지 중심 카드 데이터이고, 시리즈 탭 API는 요일/진행 여부/콘텐츠 수/소장률 데이터를 중심으로 하므로 책임을 분리하는 편이 가장 단순하다. 기존 홈 탭 모델은 `CreatorChannelHomeSeriesResponse`로 변경하고, 시리즈 탭 API 모델은 명세와 동일하게 `CreatorChannelSeriesResponse`를 사용한다.
|
||||
|
||||
Figma 참조:
|
||||
- 전체 화면: `290:9031`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9031&m=dev`
|
||||
- 시리즈 아이템: `290:9036`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9036&m=dev`
|
||||
- 시리즈 콘텐츠 소장률: `290:9038`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9038&m=dev`
|
||||
|
||||
## 2. Problem
|
||||
- 현재 `CreatorChannelTab.series`는 placeholder로 표시되어 실제 시리즈 목록, 정렬, 소장률, 페이지네이션이 없다.
|
||||
- 홈 탭의 시리즈 카드는 커버 중심의 가로 섹션이므로, 시리즈 탭의 리스트형 아이템과 API 응답 필드를 그대로 재사용할 수 없다.
|
||||
- 시리즈 탭은 오디오 탭과 같은 정렬 선택 UX를 사용해야 하지만, 시리즈별 콘텐츠 수와 유료 콘텐츠 소장률 표시 조건이 추가된다.
|
||||
- 본인 채널에서는 일반 사용자용 소장률을 숨기고 상단 정보만 표시해야 한다.
|
||||
|
||||
## 3. Goals
|
||||
- `CreatorChannelTab.series` 선택 시 시리즈 탭 API를 호출하고 응답 데이터로 화면을 구성한다.
|
||||
- `page`, `size`, `sort`를 query parameter로 전달한다.
|
||||
- 기본값은 `page=0`, `size=20`, `sort=LATEST`이다.
|
||||
- 정렬 선택 방식은 오디오 탭과 동일한 `CreatorChannelSortBar` + context popup을 재사용한다.
|
||||
- sort-bar는 `seriesCount`가 `0` 이상이면 `전체 {seriesCount}`를 표시한다.
|
||||
- 시리즈 아이템은 Figma `290:9036` 기준으로 신규 리스트 item을 구현한다.
|
||||
- 시리즈 아이템 우측 끝 버튼은 표시하지 않고, 해당 버튼 왼쪽 UI가 play button 영역까지 차지하도록 구성한다.
|
||||
- 본인 채널이 아닐 때만 시리즈 콘텐츠 소장률 UI를 표시한다.
|
||||
- 본인 채널이면 소장률을 숨기고 상단 info만 표시한다.
|
||||
- 목록 하단 도달 시 `hasNext == true`이면 다음 페이지를 조회해 append한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
- 크리에이터 채널 공통 shell, header, sticky tab-bar 동작을 다시 설계하지 않는다.
|
||||
- 시리즈 상세 화면, 시리즈 재생, 구매, 업로드 화면 자체를 새로 구현하지 않는다.
|
||||
- 오디오 콘텐츠 목록 item을 시리즈 item으로 억지 재사용하지 않는다.
|
||||
- Figma 시리즈 아이템의 가장 우측 버튼은 이번 범위에서 구현하지 않는다.
|
||||
- 이미지 크기처럼 명확한 제한이 필요한 경우를 제외하고 불필요한 고정 크기 상수를 추가하지 않는다.
|
||||
- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다.
|
||||
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
|
||||
|
||||
## 5. Core Requirements
|
||||
|
||||
### 5.1 API
|
||||
- Method: `GET`
|
||||
- Path: `/api/v2/creator-channels/{creatorId}/series`
|
||||
- Path parameter: `creatorId`
|
||||
- Query parameters:
|
||||
- `sort`
|
||||
- `page`
|
||||
- `size`
|
||||
- 기본 query:
|
||||
- `page=0`
|
||||
- `size=20`
|
||||
- `sort=LATEST`
|
||||
- 응답 래퍼는 기존 패턴대로 `ApiResponse<CreatorChannelSeriesTabResponse>`로 디코딩한다.
|
||||
|
||||
```kotlin
|
||||
data class CreatorChannelSeriesTabResponse(
|
||||
val seriesCount: Int,
|
||||
val series: List<CreatorChannelSeriesResponse>,
|
||||
val sort: ContentSort,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
)
|
||||
|
||||
data class CreatorChannelSeriesResponse(
|
||||
val seriesId: Long,
|
||||
val title: String,
|
||||
val publishedDaysOfWeek: String,
|
||||
val isOriginal: Boolean,
|
||||
val isAdult: Boolean,
|
||||
val isProceeding: Boolean,
|
||||
val contentCount: Int,
|
||||
val purchasedContentCount: Int?,
|
||||
val paidContentCount: Int?,
|
||||
val purchasedPaidContentRate: Int?
|
||||
)
|
||||
```
|
||||
|
||||
- `ContentSort`는 기존 타입을 재사용한다.
|
||||
- Kotlin `Long`은 기존 V2 모델 관례대로 Swift `Int`로 선언한다.
|
||||
- 기존 홈 탭의 `CreatorChannelSeriesResponse`는 `coverImageUrl`, `numberOfContent`, `isNew` 등 필드가 달라 시리즈 탭 응답 모델로 재사용하지 않는다.
|
||||
- 기존 홈 탭 타입명을 `CreatorChannelHomeSeriesResponse`로 변경한다.
|
||||
- 시리즈 탭 item 모델명은 API 명세와 동일하게 `CreatorChannelSeriesResponse`로 선언한다.
|
||||
- `CreatorChannelSeriesTabResponse.series`는 `[CreatorChannelSeriesResponse]`로 선언한다.
|
||||
|
||||
### 5.2 Sort
|
||||
- 정렬 선택 UI는 오디오 탭과 동일하게 sort-bar 우측 버튼 아래 anchored context popup으로 표시한다.
|
||||
- 정렬 변경 시 첫 페이지부터 다시 조회한다.
|
||||
- 시리즈 탭에서 사용하는 `ContentSort` 값은 현재 오디오 탭과 동일하게 4개만 사용한다.
|
||||
- `LATEST`
|
||||
- `POPULAR`
|
||||
- `PRICE_HIGH`
|
||||
- `PRICE_LOW`
|
||||
- `OWNED`는 사용하지 않는다.
|
||||
|
||||
### 5.3 Series item
|
||||
- 시리즈 목록은 세로 리스트로 표시한다.
|
||||
- 아이템 tap 시 기존 `showSeriesDetail(_:)` 흐름을 재사용해 `seriesDetail(seriesId:)`로 이동한다.
|
||||
- 표시 정보는 API 응답의 `title`, `publishedDaysOfWeek`, `isOriginal`, `isAdult`, `isProceeding`, `contentCount`를 사용한다.
|
||||
- `publishedDaysOfWeek`는 서버 문자열을 그대로 표시한다.
|
||||
- `isProceeding`은 진행 중/완결 상태 표시 UI에 사용한다.
|
||||
- 썸네일 overlay의 `ONLY` 표시는 `isOriginal == true`일 때만 표시한다.
|
||||
- `ONLY` 표시는 홈 탭 시리즈 카드와 동일하게 `ic_series_original`과 `img_new_only`를 조합해 사용한다.
|
||||
- 아이템 우측에 표시되는 성인 태그는 `CreatorChannelAudioAdultTag`를 재사용한다.
|
||||
- Figma `290:9036` 기준 성인 태그는 `top: 6`, `right: 6` 위치에 있으므로, `isAdult == true`일 때 `CreatorChannelAudioAdultTag().padding(.top, 6).padding(.trailing, 6)` 형태로 우측 상단에 배치한다.
|
||||
- Figma `290:9036`에서 가장 우측에 있는 버튼은 표시하지 않는다.
|
||||
- 숨긴 버튼의 왼쪽 UI는 썸네일 overlay asset이 아니라, 아이템 가로 레이아웃에서 가장 우측 버튼 바로 왼쪽에 배치된 정보 영역을 의미한다.
|
||||
- 해당 정보 영역은 기존 play button 영역까지 확장되도록 `frame(maxWidth: .infinity, alignment: ...)` 등 SwiftUI의 유연한 레이아웃을 우선 사용한다.
|
||||
- 이미지 크기처럼 Figma와 기능상 일치가 필요한 영역만 명시적으로 크기를 제한한다.
|
||||
|
||||
### 5.4 Ownership rate
|
||||
- 소장률 UI는 본인 채널이 아닐 때만 표시한다.
|
||||
- 본인 채널에서는 소장률 progress/count 영역을 숨기고 Figma `290:9038`의 상단 info 영역만 표시한다.
|
||||
- `purchasedPaidContentRate`는 percent 값으로 내려오므로 그대로 `%` 문구에 사용한다.
|
||||
- 우측 카운트는 `purchasedContentCount/paidContentCount개` 형식으로 표시한다.
|
||||
- progress 값은 `purchasedPaidContentRate / 100`을 0...1로 clamp해서 사용한다.
|
||||
- `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate` 중 표시 필수 값이 `nil`이면 소장률 영역은 표시하지 않는다.
|
||||
|
||||
### 5.5 Empty and pagination
|
||||
- Empty 상태 판정은 서버의 `seriesCount == 0` 기준으로 한다.
|
||||
- Empty UI는 라이브/오디오 탭 empty와 동일한 표시 방식을 우선 재사용한다.
|
||||
- Empty 문구는 `크리에이터가 시리즈를 준비 중입니다.\n기대해 주세요!`를 기본안으로 `I18n`에 추가한다.
|
||||
- 목록 하단 도달 시 `hasNext == true`이면 `page + 1`을 조회해 append한다.
|
||||
|
||||
## 6. Success Criteria
|
||||
- 시리즈 탭 진입 시 `GET /api/v2/creator-channels/{creatorId}/series?page=0&size=20&sort=LATEST`가 호출된다.
|
||||
- 정렬 선택 UI는 BottomSheet가 아니라 sort-bar 우측 버튼 아래 anchored context popup으로 표시되고, 동작은 오디오 탭과 동일하다.
|
||||
- 정렬 변경 시 선택한 `sort`로 첫 페이지를 다시 조회한다.
|
||||
- `seriesCount`가 sort-bar에 `전체 {seriesCount}` 형식으로 표시된다.
|
||||
- 시리즈 리스트 item은 Figma `290:9036` 기준으로 표시하되, 가장 우측 버튼은 표시하지 않는다.
|
||||
- 숨긴 버튼의 왼쪽 UI가 play button 영역까지 자연스럽게 확장된다.
|
||||
- 본인 채널이 아니고 소장률 필수 값이 모두 있을 때만 시리즈 콘텐츠 소장률 UI가 표시된다.
|
||||
- 본인 채널에서는 소장률이 표시되지 않고 상단 info만 표시된다.
|
||||
- `seriesCount == 0`이면 empty UI가 표시된다.
|
||||
- `hasNext == true`인 상태에서 목록 하단에 도달하면 다음 페이지를 append한다.
|
||||
- 시리즈 item tap 시 기존 시리즈 상세 이동 흐름으로 이동한다.
|
||||
|
||||
## 7. Technical Constraints
|
||||
- 기능 변경은 `SodaLive/Sources/V2/CreatorChannel/**` 하위에서 해결한다.
|
||||
- 시리즈 탭 전용 View, ViewModel, Repository, API, 모델은 `SodaLive/Sources/V2/CreatorChannel/Series/**` 아래에 둔다.
|
||||
- 공용 sort bar, sort context popup은 기존 구현을 재사용한다.
|
||||
- 기존 홈 탭 시리즈 카드/모델과 시리즈 탭 리스트/모델의 책임을 분리하고, 홈 탭 타입은 `CreatorChannelHomeSeriesResponse`로 이름을 변경한다.
|
||||
- 신규 문구가 필요하면 `SodaLive/Sources/I18n/I18n.swift`에 ko/en/ja를 추가한다.
|
||||
|
||||
## 8. Design Decisions
|
||||
- 시리즈 탭은 홈 탭 시리즈 모델을 확장하지 않고 `Series/**` 하위 전용 모델/ViewModel/View를 둔다.
|
||||
- 기존 홈 탭 시리즈 모델은 `CreatorChannelHomeSeriesResponse`로 변경하고, 신규 시리즈 탭 모델은 `CreatorChannelSeriesResponse`로 둔다.
|
||||
- 정렬은 오디오 탭과 동일한 4개 값(`LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`)만 사용하고 `OWNED`는 제외한다.
|
||||
- 시리즈 item은 오디오 콘텐츠 item을 변형하지 않고 신규 `CreatorChannelSeriesListItem`으로 구현한다.
|
||||
- 썸네일 overlay는 `isOriginal == true`일 때의 `ONLY` 표시만 담당하고, 우측 성인 태그는 `CreatorChannelAudioAdultTag`를 `top 6`, `trailing 6` padding으로 재사용한다.
|
||||
- 시리즈 item의 우측 버튼 제거 후 확장 대상은 썸네일 overlay asset이 아니라 버튼 바로 왼쪽의 정보 영역이다.
|
||||
- 본인 채널 여부에 따른 소장률 숨김은 item 내부가 아니라 시리즈 탭 view에서 제어한다.
|
||||
|
||||
## 9. Open Questions
|
||||
- 해당 없음.
|
||||
|
||||
## 10. Verification Notes
|
||||
- 2026-07-04: 사용자 제공 API 명세, 정렬 방식, Figma 링크, 본인 채널 소장률 표시 조건을 문서에 반영했다.
|
||||
- 2026-07-04: 기존 `CreatorChannelAudioTabResponse`, `CreatorChannelSortBar`, `CreatorChannelSortContextPopup`, 홈 탭 `CreatorChannelSeriesResponse`, `showSeriesDetail(_:)` 경로를 확인했다.
|
||||
- 2026-07-04: 현재 Swift `ContentSort`가 오디오 탭 정렬 4개(`LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`)를 제공하는 것을 확인했다.
|
||||
- 2026-07-04: 사용자 확인에 따라 `OWNED`는 시리즈 탭 정렬 범위에서 제외했다.
|
||||
- 2026-07-04: `superpowers:brainstorming` 관점으로 모델 재사용/확장 선택지를 검토하고, 시리즈 탭 전용 모듈을 선택한 이유를 문서에 추가했다.
|
||||
- 2026-07-04: "버튼 왼쪽 UI"는 썸네일 overlay asset이 아니라 시리즈 item 우측 버튼 바로 왼쪽의 정보 영역을 의미하는 것으로 명확히 정리했다.
|
||||
- 2026-07-04: 썸네일 overlay는 `isOriginal == true`일 때 `ic_series_original` + `img_new_only`로 표시하고, 우측 성인 태그는 `CreatorChannelAudioAdultTag().padding(.top, 6).padding(.trailing, 6)`로 재사용하도록 반영했다.
|
||||
- 2026-07-04: Figma `290:9036`의 design context에서 성인 태그 위치가 `top: 6`, `right: 6`임을 확인해 padding 값을 유지했다.
|
||||
- 2026-07-04: 기존 홈 탭 시리즈 모델은 `CreatorChannelHomeSeriesResponse`로 변경하고, 신규 시리즈 탭 API 모델은 명세와 동일한 `CreatorChannelSeriesResponse`를 사용하도록 결정했다.
|
||||
Reference in New Issue
Block a user