From bae92eba4bc9a6977831184bdbd67f3c71cc8eff Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 6 Jul 2026 16:28:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(content):=20=EC=A0=84=EC=B2=B4=20=ED=83=AD?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 80 +++++ SodaLive/Sources/Home/HomeTabViewModel.swift | 2 +- SodaLive/Sources/I18n/I18n.swift | 51 +++ .../AudioContentThumbnailCard.swift | 14 + .../MainContentSeriesThumbnailCard.swift | 78 +++++ .../Main/Content/All/MainContentAllView.swift | 200 +++++++++++ .../Content/All/MainContentAllViewModel.swift | 233 +++++++++++++ .../Models/MainContentAllTabResponse.swift | 71 ++++ .../All/Repository/MainContentAllApi.swift | 55 ++++ .../Repository/MainContentAllRepository.swift | 27 ++ .../V2/Main/Content/MainContentTab.swift | 6 + .../V2/Main/Content/MainContentView.swift | 9 + .../plan-task.md | 97 ++++++ docs/20260706_메인_콘텐츠_내부_전체_탭/prd.md | 311 ++++++++++++++++++ 14 files changed, 1233 insertions(+), 1 deletion(-) create mode 100644 SodaLive/Sources/V2/Main/Content/All/Components/MainContentSeriesThumbnailCard.swift create mode 100644 SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift create mode 100644 SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift create mode 100644 SodaLive/Sources/V2/Main/Content/All/Models/MainContentAllTabResponse.swift create mode 100644 SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllApi.swift create mode 100644 SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllRepository.swift create mode 100644 docs/20260706_메인_콘텐츠_내부_전체_탭/plan-task.md create mode 100644 docs/20260706_메인_콘텐츠_내부_전체_탭/prd.md diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 2b57faf1..08ca773d 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -7,6 +7,24 @@ objects = { /* Begin PBXBuildFile section */ + C0A711000000000000000001 /* MainContentAllTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000001 /* MainContentAllTabResponse.swift */; }; + C0A712000000000000000001 /* MainContentAllTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000001 /* MainContentAllTabResponse.swift */; }; + C0A713000000000000000001 /* MainContentAllTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000001 /* MainContentAllTabResponse.swift */; }; + C0A711000000000000000002 /* MainContentAllApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000002 /* MainContentAllApi.swift */; }; + C0A712000000000000000002 /* MainContentAllApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000002 /* MainContentAllApi.swift */; }; + C0A713000000000000000002 /* MainContentAllApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000002 /* MainContentAllApi.swift */; }; + C0A711000000000000000003 /* MainContentAllRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000003 /* MainContentAllRepository.swift */; }; + C0A712000000000000000003 /* MainContentAllRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000003 /* MainContentAllRepository.swift */; }; + C0A713000000000000000003 /* MainContentAllRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000003 /* MainContentAllRepository.swift */; }; + C0A711000000000000000004 /* MainContentAllViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000004 /* MainContentAllViewModel.swift */; }; + C0A712000000000000000004 /* MainContentAllViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000004 /* MainContentAllViewModel.swift */; }; + C0A713000000000000000004 /* MainContentAllViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000004 /* MainContentAllViewModel.swift */; }; + C0A711000000000000000005 /* MainContentAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000005 /* MainContentAllView.swift */; }; + C0A712000000000000000005 /* MainContentAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000005 /* MainContentAllView.swift */; }; + C0A713000000000000000005 /* MainContentAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000005 /* MainContentAllView.swift */; }; + C0A711000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000006 /* MainContentSeriesThumbnailCard.swift */; }; + C0A712000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000006 /* MainContentSeriesThumbnailCard.swift */; }; + C0A713000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A710000000000000000006 /* MainContentSeriesThumbnailCard.swift */; }; C0A701000000000000000001 /* MainContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000001 /* MainContentView.swift */; }; C0A701000000000000000002 /* MainContentTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000002 /* MainContentTab.swift */; }; C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000003 /* MainContentRecommendationView.swift */; }; @@ -2078,6 +2096,12 @@ 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationView.swift; sourceTree = ""; }; 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingView.swift; sourceTree = ""; }; 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingView.swift; sourceTree = ""; }; + C0A710000000000000000001 /* MainContentAllTabResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAllTabResponse.swift; sourceTree = ""; }; + C0A710000000000000000002 /* MainContentAllApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAllApi.swift; sourceTree = ""; }; + C0A710000000000000000003 /* MainContentAllRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAllRepository.swift; sourceTree = ""; }; + C0A710000000000000000004 /* MainContentAllViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAllViewModel.swift; sourceTree = ""; }; + C0A710000000000000000005 /* MainContentAllView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAllView.swift; sourceTree = ""; }; + C0A710000000000000000006 /* MainContentSeriesThumbnailCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentSeriesThumbnailCard.swift; sourceTree = ""; }; C0A700000000000000000001 /* MainContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentView.swift; sourceTree = ""; }; C0A700000000000000000002 /* MainContentTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentTab.swift; sourceTree = ""; }; C0A700000000000000000003 /* MainContentRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendationView.swift; sourceTree = ""; }; @@ -3155,10 +3179,48 @@ path = Like; sourceTree = ""; }; + C0A710000000000000000100 /* All */ = { + isa = PBXGroup; + children = ( + C0A710000000000000000103 /* Components */, + C0A710000000000000000101 /* Models */, + C0A710000000000000000102 /* Repository */, + C0A710000000000000000004 /* MainContentAllViewModel.swift */, + C0A710000000000000000005 /* MainContentAllView.swift */, + ); + path = All; + sourceTree = ""; + }; + C0A710000000000000000101 /* Models */ = { + isa = PBXGroup; + children = ( + C0A710000000000000000001 /* MainContentAllTabResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + C0A710000000000000000102 /* Repository */ = { + isa = PBXGroup; + children = ( + C0A710000000000000000002 /* MainContentAllApi.swift */, + C0A710000000000000000003 /* MainContentAllRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; + C0A710000000000000000103 /* Components */ = { + isa = PBXGroup; + children = ( + C0A710000000000000000006 /* MainContentSeriesThumbnailCard.swift */, + ); + path = Components; + sourceTree = ""; + }; C0A700000000000000000101 /* Content */ = { isa = PBXGroup; children = ( C0A700000000000000000102 /* Recommendation */, + C0A710000000000000000100 /* All */, C0A700000000000000000001 /* MainContentView.swift */, C0A700000000000000000002 /* MainContentTab.swift */, ); @@ -6334,6 +6396,12 @@ 401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */, 401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, C0A704000000000000000003 /* AudioContentListRow.swift in Sources */, + C0A711000000000000000001 /* MainContentAllTabResponse.swift in Sources */, + C0A711000000000000000002 /* MainContentAllApi.swift in Sources */, + C0A711000000000000000003 /* MainContentAllRepository.swift in Sources */, + C0A711000000000000000004 /* MainContentAllViewModel.swift in Sources */, + C0A711000000000000000005 /* MainContentAllView.swift in Sources */, + C0A711000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */, C0A701000000000000000001 /* MainContentView.swift in Sources */, C0A701000000000000000002 /* MainContentTab.swift in Sources */, C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */, @@ -7190,6 +7258,12 @@ 401A3FD92FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */, 401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */, 401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, + C0A712000000000000000001 /* MainContentAllTabResponse.swift in Sources */, + C0A712000000000000000002 /* MainContentAllApi.swift in Sources */, + C0A712000000000000000003 /* MainContentAllRepository.swift in Sources */, + C0A712000000000000000004 /* MainContentAllViewModel.swift in Sources */, + C0A712000000000000000005 /* MainContentAllView.swift in Sources */, + C0A712000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */, C0A702000000000000000001 /* MainContentView.swift in Sources */, C0A702000000000000000002 /* MainContentTab.swift in Sources */, C0A702000000000000000003 /* MainContentRecommendationView.swift in Sources */, @@ -8086,6 +8160,12 @@ 401A3FDA2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */, 401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */, 401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, + C0A713000000000000000001 /* MainContentAllTabResponse.swift in Sources */, + C0A713000000000000000002 /* MainContentAllApi.swift in Sources */, + C0A713000000000000000003 /* MainContentAllRepository.swift in Sources */, + C0A713000000000000000004 /* MainContentAllViewModel.swift in Sources */, + C0A713000000000000000005 /* MainContentAllView.swift in Sources */, + C0A713000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */, C0A703000000000000000001 /* MainContentView.swift in Sources */, C0A703000000000000000002 /* MainContentTab.swift in Sources */, C0A703000000000000000003 /* MainContentRecommendationView.swift in Sources */, diff --git a/SodaLive/Sources/Home/HomeTabViewModel.swift b/SodaLive/Sources/Home/HomeTabViewModel.swift index 98384d22..2e6ebd02 100644 --- a/SodaLive/Sources/Home/HomeTabViewModel.swift +++ b/SodaLive/Sources/Home/HomeTabViewModel.swift @@ -8,7 +8,7 @@ import Foundation import Combine -enum SeriesPublishedDaysOfWeek: String, Encodable { +enum SeriesPublishedDaysOfWeek: String, Encodable, Decodable, CaseIterable, Hashable { case SUN, MON, TUE, WED, THU, FRI, SAT, RANDOM } diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index a4c23010..70caf13d 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3049,6 +3049,57 @@ If you block this user, the following features will be restricted. } + enum MainContentRanking { + static var tabTitle: String { + pick(ko: "랭킹", en: "Ranking", ja: "ランキング") + } + } + + enum MainContentAll { + static var tabTitle: String { + pick(ko: "전체", en: "All", ja: "すべて") + } + + static var rangeLabel: String { + pick(ko: "전체", en: "All", ja: "すべて") + } + + static var emptyStateMessage: String { + pick( + ko: "콘텐츠가 아직 없어요.", + en: "No content yet.", + ja: "コンテンツはまだありません。" + ) + } + + static var loadFailedMessage: String { + pick( + ko: "콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.", + en: "Unable to load content.\nPlease try again later.", + ja: "コンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。" + ) + } + + enum TypeChip { + static var audio: String { pick(ko: "오디오", en: "Audio", ja: "オーディオ") } + static var series: String { pick(ko: "시리즈", en: "Series", ja: "シリーズ") } + static var original: String { pick(ko: "오리지널", en: "Original", ja: "オリジナル") } + static var free: String { pick(ko: "무료", en: "Free", ja: "無料") } + static var point: String { pick(ko: "포인트", en: "Point", ja: "ポイント") } + } + + enum DayOfWeek { + static var mon: String { pick(ko: "월", en: "MON", ja: "月") } + static var tue: String { pick(ko: "화", en: "TUE", ja: "火") } + static var wed: String { pick(ko: "수", en: "WED", ja: "水") } + static var thu: String { pick(ko: "목", en: "THU", ja: "木") } + static var fri: String { pick(ko: "금", en: "FRI", ja: "金") } + static var sat: String { pick(ko: "토", en: "SAT", ja: "土") } + static var sun: String { pick(ko: "일", en: "SUN", ja: "日") } + static var random: String { pick(ko: "기타", en: "OTHER", ja: "その他") } + } + } + enum Home { static var liveNowSectionTitle: String { pick(ko: "지금 라이브중", en: "Live now", ja: "ライブ配信中") diff --git a/SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift b/SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift index f9b388d9..e3cc7040 100644 --- a/SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift +++ b/SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift @@ -189,6 +189,20 @@ extension AudioContentThumbnailCardItem { isOriginalSeries: audioCard.isOriginalSeries ) } + + init(mainContentAudio: MainContentAudioResponse) { + self.init( + id: mainContentAudio.audioContentId, + title: mainContentAudio.title, + subtitle: mainContentAudio.creatorNickname, + imageUrl: mainContentAudio.imageUrl, + price: mainContentAudio.price, + isAdult: mainContentAudio.isAdult, + isPointAvailable: mainContentAudio.isPointAvailable, + isFirstContent: mainContentAudio.isFirstContent, + isOriginalSeries: mainContentAudio.isOriginalSeries + ) + } } struct AudioContentThumbnailCard_Previews: PreviewProvider { diff --git a/SodaLive/Sources/V2/Main/Content/All/Components/MainContentSeriesThumbnailCard.swift b/SodaLive/Sources/V2/Main/Content/All/Components/MainContentSeriesThumbnailCard.swift new file mode 100644 index 00000000..8da6a9f5 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/All/Components/MainContentSeriesThumbnailCard.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct MainContentSeriesThumbnailCard: View { + let item: MainContentSeriesResponse + let width: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s8) { + thumbnail + + labelContent + } + .frame(width: width, alignment: .leading) + } + + private var thumbnail: some View { + ZStack(alignment: .topLeading) { + DownsampledKFImage( + url: item.coverImageUrl.flatMap(URL.init(string:)), + size: CGSize(width: width, height: width * 1.41) + ) + .background(Color.gray800) + + if item.isOriginal { + originalTag + } + + HStack(spacing: 0) { + Spacer(minLength: 0) + + if item.isAdult { + CreatorChannelAudioAdultTag() + .padding(.top, SodaSpacing.s6) + .padding(.trailing, SodaSpacing.s6) + } + } + } + .frame(width: width, height: width * 1.41) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + + private var originalTag: some View { + HStack(spacing: SodaSpacing.s4) { + Image("ic_series_original") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + + Image("img_new_only") + .resizable() + .scaledToFit() + .frame(height: 14) + } + .padding(.horizontal, SodaSpacing.s8) + .padding(.vertical, 5) + .background(Color.gray900) + } + + private var labelContent: some View { + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .appFont(size: 16, weight: .bold) + .foregroundColor(Color.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(item.creatorNickname) + .appFont(size: 12, weight: .medium) + .foregroundColor(Color.gray500) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(width: width - (SodaSpacing.s4 * 2), alignment: .leading) + .padding(.horizontal, SodaSpacing.s4) + .frame(width: width, alignment: .leading) + } +} + diff --git a/SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift b/SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift new file mode 100644 index 00000000..3a55503d --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift @@ -0,0 +1,200 @@ +import SwiftUI + +struct MainContentAllView: View { + @ObservedObject var viewModel: MainContentAllViewModel + let onTapContent: (Int) -> Void + let onTapSeries: (Int) -> Void + + @State private var isSortPopupPresented = false + + private let columns = [ + GridItem(.flexible(), spacing: SodaSpacing.s4), + GridItem(.flexible(), spacing: SodaSpacing.s4), + GridItem(.flexible(), spacing: SodaSpacing.s4) + ] + + var body: some View { + ZStack(alignment: .topTrailing) { + Color.black + .ignoresSafeArea() + + content + + if isSortPopupPresented { + Color.black + .opacity(0.001) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture { + isSortPopupPresented = false + } + + CreatorChannelSortContextPopup( + selectedSort: viewModel.selectedSort, + sorts: viewModel.allowedSorts + ) { sort in + viewModel.selectSort(sort) + isSortPopupPresented = false + } + .padding(.top, viewModel.selectedType == .series ? 156 : 104) + .padding(.trailing, SodaSpacing.s14) + .zIndex(1) + } + } + .onAppear { + viewModel.fetchFirstPageIfNeeded() + } + .onChange(of: viewModel.selectedSort) { _ in + isSortPopupPresented = false + } + .onChange(of: viewModel.selectedType) { _ in + isSortPopupPresented = false + } + .onDisappear { + isSortPopupPresented = false + } + .sodaToast( + isPresented: $viewModel.isShowPopup, + message: viewModel.errorMessage, + autohideIn: 2 + ) + } + + private var content: some View { + VStack(spacing: 0) { + typeFilter + + if viewModel.selectedType == .series { + dayOfWeekFilter + } + + sortBar + + if viewModel.isLoading && viewModel.hasLoaded == false { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.shouldShowEmptyState { + MainContentAudioEmptyStateView(message: I18n.MainContentAll.emptyStateMessage) + } else { + ScrollView(showsIndicators: false) { + gridContent + .padding(.horizontal, SodaSpacing.s14) + .padding(.top, SodaSpacing.s8) + .padding(.bottom, SodaSpacing.s20) + + if viewModel.isLoadingNextPage { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.vertical, SodaSpacing.s20) + } + } + } + } + } + + private var typeFilter: some View { + CapsuleTabBar( + items: MainContentAllType.allCases, + selectedItem: Binding( + get: { viewModel.selectedType }, + set: { viewModel.selectType($0) } + ), + title: { $0.title } + ) + } + + private var dayOfWeekFilter: some View { + HStack(spacing: 0) { + ForEach(SeriesPublishedDaysOfWeek.allFilterCases, id: \.self) { dayOfWeek in + Button { + viewModel.selectDayOfWeek(dayOfWeek) + } label: { + Text(dayOfWeek.shortTitle) + .appFont(size: 14, weight: .medium) + .foregroundColor(viewModel.selectedDayOfWeek == dayOfWeek ? Color.black : Color.white) + .lineLimit(1) + .frame(minWidth: 20, minHeight: 20) + .padding(SodaSpacing.s8) + .background(viewModel.selectedDayOfWeek == dayOfWeek ? Color.white : Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s8, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s8, style: .continuous)) + .padding(.vertical, SodaSpacing.s8) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color.black) + } + + private var sortBar: some View { + CreatorChannelSortBar( + selectedSort: viewModel.selectedSort, + totalCount: nil, + onTapSort: { + isSortPopupPresented.toggle() + } + ) + } + + @ViewBuilder + private var gridContent: some View { + if viewModel.selectedType.usesAudioItems { + LazyVGrid(columns: columns, alignment: .leading, spacing: SodaSpacing.s20) { + ForEach(viewModel.audios) { audio in + Button { + onTapContent(audio.audioContentId) + } label: { + GeometryReader { proxy in + AudioContentThumbnailCard( + item: AudioContentThumbnailCardItem(mainContentAudio: audio), + width: proxy.size.width, + titleTypography: .body1, + subtitleTypography: .caption2, + labelHorizontalPadding: SodaSpacing.s4 + ) + } + .aspectRatio(0.78, contentMode: .fit) + } + .buttonStyle(.plain) + .onAppear { + viewModel.fetchNextPageIfNeeded(currentAudio: audio) + } + } + } + } else { + LazyVGrid(columns: columns, alignment: .leading, spacing: SodaSpacing.s20) { + ForEach(viewModel.series) { series in + Button { + onTapSeries(series.seriesId) + } label: { + GeometryReader { proxy in + MainContentSeriesThumbnailCard( + item: series, + width: proxy.size.width + ) + } + .aspectRatio(0.55, contentMode: .fit) + } + .buttonStyle(.plain) + .onAppear { + viewModel.fetchNextPageIfNeeded(currentSeries: series) + } + } + } + } + } +} + +struct MainContentAllView_Previews: PreviewProvider { + static var previews: some View { + MainContentAllView( + viewModel: MainContentAllViewModel(), + onTapContent: { _ in }, + onTapSeries: { _ in } + ) + } +} + diff --git a/SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift b/SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift new file mode 100644 index 00000000..3b507803 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift @@ -0,0 +1,233 @@ +import Foundation +import Combine + +final class MainContentAllViewModel: ObservableObject { + private let repository = MainContentAllRepository() + private var subscription = Set() + private let pageSize = 20 + private var latestRequestId = 0 + + @Published var selectedType: MainContentAllType = .audio + @Published var selectedSort: ContentSort = .latest + @Published var selectedDayOfWeek: SeriesPublishedDaysOfWeek = .currentDeviceDay + @Published var response: MainContentAllTabResponse? + @Published var audios = [MainContentAudioResponse]() + @Published var series = [MainContentSeriesResponse]() + @Published var page = 0 + @Published var size = 20 + @Published var hasNext = false + @Published var hasLoaded = false + @Published var isLoading = false + @Published var isLoadingNextPage = false + @Published var errorMessage = "" + @Published var isShowPopup = false + + var allowedSorts: [ContentSort] { + selectedType == .free ? [.latest, .popular] : ContentSort.allCases + } + + var shouldShowEmptyState: Bool { + guard hasLoaded, isLoading == false else { return false } + return selectedType.usesAudioItems ? audios.isEmpty : series.isEmpty + } + + func fetchFirstPageIfNeeded() { + guard hasLoaded == false else { return } + fetchFirstPage() + } + + func fetchFirstPage() { + fetchContents(page: 0, isNextPage: false) + } + + func selectType(_ type: MainContentAllType) { + guard selectedType != type else { return } + + selectedType = type + if allowedSorts.contains(selectedSort) == false { + selectedSort = .latest + } + clearItems() + fetchFirstPage() + } + + func selectSort(_ sort: ContentSort) { + guard allowedSorts.contains(sort), selectedSort != sort else { return } + + selectedSort = sort + clearItems() + fetchFirstPage() + } + + func selectDayOfWeek(_ dayOfWeek: SeriesPublishedDaysOfWeek) { + guard selectedType == .series, selectedDayOfWeek != dayOfWeek else { return } + + selectedDayOfWeek = dayOfWeek + clearItems() + fetchFirstPage() + } + + func fetchNextPageIfNeeded(currentAudio: MainContentAudioResponse) { + guard audios.last?.audioContentId == currentAudio.audioContentId else { return } + fetchNextPageIfPossible() + } + + func fetchNextPageIfNeeded(currentSeries: MainContentSeriesResponse) { + guard series.last?.seriesId == currentSeries.seriesId else { return } + fetchNextPageIfPossible() + } + + private func fetchNextPageIfPossible() { + guard hasNext, isLoading == false, isLoadingNextPage == false else { return } + fetchContents(page: page + 1, isNextPage: true) + } + + private func fetchContents(page requestPage: Int, isNextPage: Bool) { + latestRequestId += 1 + let requestId = latestRequestId + + if isNextPage { + isLoadingNextPage = true + } else { + isLoading = true + } + + repository.getContents( + type: selectedType, + sort: selectedSort, + page: requestPage, + size: pageSize, + dayOfWeek: selectedDayOfWeek + ) + .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.self, from: response.data) + + if let data = decoded.data, decoded.success { + self.applySuccessState(data, isNextPage: isNextPage) + } else { + self.errorMessage = decoded.message ?? I18n.MainContentAll.loadFailedMessage + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.MainContentAll.loadFailedMessage + self.isShowPopup = true + } + + self.hasLoaded = true + self.isLoading = false + self.isLoadingNextPage = false + } + .store(in: &subscription) + } + + private func applySuccessState(_ data: MainContentAllTabResponse, isNextPage: Bool) { + response = data + selectedType = data.type + selectedSort = data.sort + if let dayOfWeek = data.dayOfWeek { + selectedDayOfWeek = dayOfWeek + } + page = data.page + size = data.size + hasNext = data.hasNext + + if data.type.usesAudioItems { + if isNextPage { + audios.append(contentsOf: data.audios) + } else { + audios = data.audios + series = [] + } + } else { + if isNextPage { + series.append(contentsOf: data.series) + } else { + series = data.series + audios = [] + } + } + } + + private func applyFailureState() { + errorMessage = I18n.MainContentAll.loadFailedMessage + isShowPopup = true + hasLoaded = true + isLoading = false + isLoadingNextPage = false + } + + private func clearItems() { + audios = [] + series = [] + response = nil + page = 0 + hasNext = false + } +} + + +extension SeriesPublishedDaysOfWeek { + static var currentDeviceDay: SeriesPublishedDaysOfWeek { + switch Calendar.current.component(.weekday, from: Date()) { + case 1: + return .SUN + case 2: + return .MON + case 3: + return .TUE + case 4: + return .WED + case 5: + return .THU + case 6: + return .FRI + case 7: + return .SAT + default: + return .SUN + } + } + + static var allFilterCases: [SeriesPublishedDaysOfWeek] { + [.MON, .TUE, .WED, .THU, .FRI, .SAT, .SUN, .RANDOM] + } + + var shortTitle: String { + switch self { + case .SUN: + return I18n.MainContentAll.DayOfWeek.sun + case .MON: + return I18n.MainContentAll.DayOfWeek.mon + case .TUE: + return I18n.MainContentAll.DayOfWeek.tue + case .WED: + return I18n.MainContentAll.DayOfWeek.wed + case .THU: + return I18n.MainContentAll.DayOfWeek.thu + case .FRI: + return I18n.MainContentAll.DayOfWeek.fri + case .SAT: + return I18n.MainContentAll.DayOfWeek.sat + case .RANDOM: + return I18n.MainContentAll.DayOfWeek.random + } + } +} + diff --git a/SodaLive/Sources/V2/Main/Content/All/Models/MainContentAllTabResponse.swift b/SodaLive/Sources/V2/Main/Content/All/Models/MainContentAllTabResponse.swift new file mode 100644 index 00000000..c4e96ab4 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/All/Models/MainContentAllTabResponse.swift @@ -0,0 +1,71 @@ +import Foundation + +enum MainContentAllType: String, Decodable, Encodable, CaseIterable, Hashable { + case audio = "AUDIO" + case series = "SERIES" + case original = "ORIGINAL" + case free = "FREE" + case point = "POINT" + + var title: String { + switch self { + case .audio: + return I18n.MainContentAll.TypeChip.audio + case .series: + return I18n.MainContentAll.TypeChip.series + case .original: + return I18n.MainContentAll.TypeChip.original + case .free: + return I18n.MainContentAll.TypeChip.free + case .point: + return I18n.MainContentAll.TypeChip.point + } + } + + var usesAudioItems: Bool { + switch self { + case .audio, .free, .point: + return true + case .series, .original: + return false + } + } +} + +struct MainContentAllTabResponse: Decodable { + let type: MainContentAllType + let totalCount: Int + let audios: [MainContentAudioResponse] + let series: [MainContentSeriesResponse] + let sort: ContentSort + let dayOfWeek: SeriesPublishedDaysOfWeek? + let page: Int + let size: Int + let hasNext: Bool +} + +struct MainContentAudioResponse: Decodable, Identifiable { + let audioContentId: Int + let title: String + let imageUrl: String? + let price: Int + let isAdult: Bool + let isPointAvailable: Bool + let isFirstContent: Bool + let isOriginalSeries: Bool + let creatorNickname: String + + var id: Int { audioContentId } +} + +struct MainContentSeriesResponse: Decodable, Identifiable { + let seriesId: Int + let title: String + let coverImageUrl: String? + let creatorNickname: String + let isOriginal: Bool + let isAdult: Bool + + var id: Int { seriesId } +} + diff --git a/SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllApi.swift b/SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllApi.swift new file mode 100644 index 00000000..fa9cfabc --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllApi.swift @@ -0,0 +1,55 @@ +import Foundation +import Moya + +enum MainContentAllApi { + case getContents( + type: MainContentAllType, + sort: ContentSort, + page: Int, + size: Int, + dayOfWeek: SeriesPublishedDaysOfWeek + ) +} + +extension MainContentAllApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getContents: + return "/api/v2/audio/contents" + } + } + + var method: Moya.Method { + switch self { + case .getContents: + return .get + } + } + + var task: Moya.Task { + switch self { + case .getContents(let type, let sort, let page, let size, let dayOfWeek): + var parameters: [String: Any] = [ + "type": type.rawValue, + "sort": sort.rawValue, + "page": page, + "size": size + ] + + if type == .series { + parameters["dayOfWeek"] = dayOfWeek.rawValue + } + + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + } + } + + var headers: [String: String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} + diff --git a/SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllRepository.swift b/SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllRepository.swift new file mode 100644 index 00000000..979adc8b --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllRepository.swift @@ -0,0 +1,27 @@ +import Foundation +import Combine +import CombineMoya +import Moya + +final class MainContentAllRepository { + private let api = MoyaProvider() + + func getContents( + type: MainContentAllType, + sort: ContentSort, + page: Int, + size: Int, + dayOfWeek: SeriesPublishedDaysOfWeek + ) -> AnyPublisher { + return api.requestPublisher( + .getContents( + type: type, + sort: sort, + page: page, + size: size, + dayOfWeek: dayOfWeek + ) + ) + } +} + diff --git a/SodaLive/Sources/V2/Main/Content/MainContentTab.swift b/SodaLive/Sources/V2/Main/Content/MainContentTab.swift index 3e68cc80..f6a30203 100644 --- a/SodaLive/Sources/V2/Main/Content/MainContentTab.swift +++ b/SodaLive/Sources/V2/Main/Content/MainContentTab.swift @@ -2,11 +2,17 @@ import Foundation enum MainContentTab: CaseIterable, Hashable { case recommendation + case ranking + case all var title: String { switch self { case .recommendation: return I18n.MainContentRecommendation.tabTitle + case .ranking: + return I18n.MainContentRanking.tabTitle + case .all: + return I18n.MainContentAll.tabTitle } } } diff --git a/SodaLive/Sources/V2/Main/Content/MainContentView.swift b/SodaLive/Sources/V2/Main/Content/MainContentView.swift index a4990b36..507e94e3 100644 --- a/SodaLive/Sources/V2/Main/Content/MainContentView.swift +++ b/SodaLive/Sources/V2/Main/Content/MainContentView.swift @@ -9,6 +9,7 @@ struct MainContentView: View { let onTapSeries: (Int) -> Void @State private var selectedTab: MainContentTab = .recommendation + @StateObject private var allViewModel = MainContentAllViewModel() var body: some View { VStack(spacing: 0) { @@ -46,6 +47,14 @@ struct MainContentView: View { onTapBanner: onTapBanner, onTapSeries: onTapSeries ) + case .ranking: + MainPlaceholderTabView(title: MainContentTab.ranking.title) + case .all: + MainContentAllView( + viewModel: allViewModel, + onTapContent: onTapContent, + onTapSeries: onTapSeries + ) } } } diff --git a/docs/20260706_메인_콘텐츠_내부_전체_탭/plan-task.md b/docs/20260706_메인_콘텐츠_내부_전체_탭/plan-task.md new file mode 100644 index 00000000..549a3a1b --- /dev/null +++ b/docs/20260706_메인_콘텐츠_내부_전체_탭/plan-task.md @@ -0,0 +1,97 @@ +# 메인 콘텐츠 탭 내부 전체 탭 구현 계획 + +## 기준 문서 + +- PRD: `docs/20260706_메인_콘텐츠_내부_전체_탭/prd.md` +- 문서 정책: `docs/agent-guides/documentation-policy.md` +- 코드 스타일: `docs/agent-guides/code-style.md` +- 빌드/검증: `docs/agent-guides/build-test-verification.md` + +## 구현할 UI의 Figma URL + +- 오디오 선택 상태: `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=35-5857&m=dev` +- 시리즈 선택 상태: `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=24-6909&m=dev` +- 오리지널 선택 상태: `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=24-9105&m=dev` + +## 확정 요구사항 요약 + +- API endpoint는 `GET /api/v2/audio/contents`를 사용한다. +- 기본 query는 `type=AUDIO`, `sort=LATEST`, `page=0`, `size=20`이다. +- `dayOfWeek`는 `type == SERIES`일 때만 전송한다. +- Figma 타입 chip의 `전체`는 구현하지 않는다. +- Figma sort menu의 `추천순`은 구현하지 않는다. +- `FREE` 타입의 sort menu는 `최신순`, `인기순`만 제공한다. +- response의 `totalCount`는 UI에 표시하지 않는다. +- 내부 `추천/랭킹/전체` 탭 전환 시 `전체` 탭 상태를 유지한다. + +## TASK 체크리스트 + +### Phase 1: 전체 탭 기준 구조와 API 모델 + +- [x] **Task 1.1: 내부 전체 탭 진입 구조와 상태 유지 설계** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Content/MainContentTab.swift` + - 수정: `SodaLive/Sources/V2/Main/Content/MainContentView.swift` + - 검증 기준: + - 내부 `전체` 탭이 추가되고, `추천/랭킹/전체` 탭 전환 시 `전체` 탭 상태가 유지된다. + +- [x] **Task 1.2: API 모델과 repository 추가** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Content/All/Models/MainContentAllTabResponse.swift` + - 생성: `SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllApi.swift` + - 생성: `SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllRepository.swift` + - 검증 기준: + - `GET /api/v2/audio/contents`와 query parameter 기본값, `SERIES` 전용 `dayOfWeek` 전송 조건이 반영된다. + +### Phase 2: 필터, 정렬, 페이징 + +- [x] **Task 2.1: 타입 chip, 요일 필터, sort menu 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift` + - 생성: `SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift` + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 검증 기준: + - 타입 chip에 `전체`가 없고, `추천순` sort가 없으며, `FREE` 타입은 `LATEST`, `POPULAR`만 제공한다. + - `기타` 요일은 en `OTHER`, ja `その他`로 표시된다. + +- [x] **Task 2.2: 스크롤 페이징 구현** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Content/All/MainContentAllViewModel.swift` + - 수정: `SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift` + - 검증 기준: + - `hasNext == true`일 때 다음 page가 append되고, 타입/정렬/요일 변경 시 page 0부터 다시 로드된다. + +### Phase 3: 콘텐츠 grid와 navigation + +- [x] **Task 3.1: 오디오/무료/포인트 grid 구현** + - 대상 파일: + - 확인/수정: `SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift` + - 수정: `SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift` + - 검증 기준: + - `AUDIO`, `FREE`, `POINT`는 response `audios`를 표시하고 `totalCount`는 노출하지 않는다. + +- [x] **Task 3.2: 시리즈/오리지널 grid 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Content/All/Components/MainContentSeriesThumbnailCard.swift` + - 수정: `SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift` + - 검증 기준: + - `SERIES`, `ORIGINAL`은 response `series`를 표시하고, `SERIES`에서만 요일 필터와 `dayOfWeek` 요청이 동작한다. + +- [x] **Task 3.3: 상세 이동 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Content/All/MainContentAllView.swift` + - 수정: `SodaLive/Sources/V2/Main/MainView.swift` + - 검증 기준: + - 오디오는 `audioContentId`로 오디오 상세, 시리즈는 `seriesId`로 시리즈 상세에 진입한다. + +## 검증 기록 + +- 2026-07-06: 사용자 요청에 따라 구현 대상 Figma URL을 본 문서에 기록했다. +- 2026-07-06: `MainContentTab`에 `추천/랭킹/전체` 탭을 구성하고 `MainContentView`가 `MainContentAllViewModel`을 `@StateObject`로 보유하도록 구현해 `전체` 탭 상태 유지 구조를 반영했다. +- 2026-07-06: `MainContentAllTabResponse`, `MainContentAllApi`, `MainContentAllRepository`를 추가해 `GET /api/v2/audio/contents`와 `SERIES` 전용 `dayOfWeek` query 전송 조건을 반영했다. +- 2026-07-06: `MainContentAllViewModel`과 `MainContentAllView`에 타입 chip, `SERIES` 전용 요일 필터, sort menu, page 0 reload, `hasNext` 기반 append paging을 구현했다. +- 2026-07-06: `AudioContentThumbnailCardItem(mainContentAudio:)` adapter와 `MainContentSeriesThumbnailCard`를 추가해 `AUDIO/FREE/POINT`는 `audios`, `SERIES/ORIGINAL`은 `series` grid를 표시하도록 구현했다. +- 2026-07-06: 오디오/시리즈 상세 이동은 기존 `MainContentView` callback과 `MainView`의 기존 handler를 재사용해 연결했으므로 `MainView.swift` 직접 수정은 발생하지 않았다. +- 2026-07-06: `I18n.MainContentAll`/`I18n.MainContentRanking` 문자열을 추가했고, `RANDOM` 짧은 요일 표기는 en `OTHER`, ja `その他`로 반영했다. +- 2026-07-06: `xcodebuild -workspace "SodaLive.xcworkspace" -list`로 테스트 타깃이 별도 노출되지 않음을 확인했다. +- 2026-07-06: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -sdk iphonesimulator build` 실행 결과 `** BUILD SUCCEEDED **`를 확인했다. diff --git a/docs/20260706_메인_콘텐츠_내부_전체_탭/prd.md b/docs/20260706_메인_콘텐츠_내부_전체_탭/prd.md new file mode 100644 index 00000000..b41b2fe7 --- /dev/null +++ b/docs/20260706_메인_콘텐츠_내부_전체_탭/prd.md @@ -0,0 +1,311 @@ +# PRD: 메인 콘텐츠 탭 내부 전체 탭 + +## 1. Overview +메인 하단 `콘텐츠` 탭 안의 내부 `전체` 탭에서 `GET /api/v2/audio/contents` 응답을 사용해 오디오/시리즈/오리지널/무료/포인트 콘텐츠 목록을 제공한다. + +상단 title bar와 내부 `추천/랭킹/전체` tab bar는 기존 콘텐츠 탭 구조를 유지한다. 내부 `전체` 탭 화면에서는 콘텐츠 타입 chip, 요일 필터, 정렬 필터, 콘텐츠 grid를 구성하고, 목록에는 스크롤 페이징을 적용한다. Figma 타입 chip에는 `전체`가 보이지만 API `type` enum에 대응 값이 없으므로 `전체` chip은 구현 대상에서 제외한다. + +Figma 참조: +- 오디오 선택 상태: `35:5857`, `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=35-5857&m=dev` +- 시리즈 선택 상태: `24:6909`, `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=24-6909&m=dev` +- 오리지널 선택 상태: `24:9105`, `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=24-9105&m=dev` + +## 2. Problem +- 현재 V2 메인 콘텐츠 탭은 내부 `추천` 탭 중심으로 구성되어 있고, 내부 `전체` 탭에서 타입별 콘텐츠 목록을 API 기반으로 탐색하는 요구사항이 분리되어 있지 않다. +- 같은 endpoint가 `type`에 따라 `audios` 또는 `series` 배열을 내려주므로, 선택 타입별 데이터 매핑 규칙을 명확히 해야 한다. +- `SERIES` 타입에서만 `dayOfWeek` query parameter를 보내야 하므로, 요일 필터 UI와 API 요청 조건을 분리해야 한다. +- 기존 `SeriesPublishedDaysOfWeek`는 legacy 영역에 있고, 짧은 요일 다국어 표기 중 `기타`의 영어/일본어 요구사항이 기존 `Random` 표기와 다르다. + +## 3. Goals +- 메인 콘텐츠 탭 내부 `전체` 탭을 추가한다. +- 콘텐츠 타입 기본 선택값은 `AUDIO`로 둔다. +- Figma 타입 chip 중 `전체` chip은 구현하지 않고 `오디오`, `시리즈`, `오리지널`, `무료`, `포인트`만 제공한다. +- `GET /api/v2/audio/contents`를 호출하고 `type`, `sort`, `page`, `size`, `dayOfWeek` query parameter를 요구사항대로 전송한다. +- 기본 query 값은 `page = 0`, `size = 20`, `sort = LATEST`, `type = AUDIO`로 둔다. +- `dayOfWeek` 기본값은 현재 디바이스에 설정된 요일로 둔다. +- `dayOfWeek`는 `type == SERIES`일 때만 요청 query에 포함한다. +- `AUDIO`, `FREE`, `POINT` 선택 시 response의 `audios`를 표시한다. +- `SERIES`, `ORIGINAL` 선택 시 response의 `series`를 표시한다. +- 목록 하단 도달 시 `hasNext == true`이면 다음 page를 로드해 append한다. +- 내부 `추천/랭킹/전체` 탭 전환 시 `전체` 탭의 선택 타입, 정렬, 요일, 로드된 목록 상태를 유지한다. +- response의 `totalCount`는 모델에는 반영하되 UI에는 표시하지 않는다. +- Figma 기준 오디오/무료/포인트는 `AudioContentThumbnailCard` 재사용을 우선한다. +- Figma 기준 시리즈/오리지널은 기존 시리즈 카드 UI 후보를 확인하고, 응답 모델에 맞는 전용 adapter 또는 전용 thumbnail card를 사용한다. +- 요일 짧은 표기에서 `기타`는 영어 `OTHER`, 일본어 `その他`로 표시한다. + +## 4. Non-Goals +- 실제 구현과 Xcode 프로젝트 수정은 이번 범위에 포함하지 않는다. +- 추천 탭의 `GET /api/v2/audio/recommendations` 구현 범위는 변경하지 않는다. +- 랭킹 탭 API/화면은 이번 범위에 포함하지 않는다. +- Figma 타입 chip의 `전체` chip은 구현하지 않는다. +- Figma sort menu의 `추천순`은 구현하지 않는다. +- response의 `totalCount`를 화면에 표시하지 않는다. +- Figma에 보이는 localhost asset URL을 앱 코드에 직접 사용하지 않는다. +- API 명세에 없는 신규 `type` 값을 임의로 추가하지 않는다. +- 외부 라이브러리를 추가하지 않는다. +- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. + +## 5. Target Users +- 메인 콘텐츠 탭에서 전체 콘텐츠를 타입별로 빠르게 탐색하려는 사용자 +- 오디오/무료/포인트 콘텐츠를 썸네일 grid로 훑고 상세로 이동하려는 사용자 +- 시리즈와 오리지널 시리즈를 구분해 탐색하려는 사용자 +- 요일별 시리즈를 현재 디바이스 요일 기준으로 바로 확인하려는 사용자 + +## 6. User Stories +- 사용자는 콘텐츠 탭의 내부 `전체` 탭에 진입하면 기본으로 오디오 목록을 보고 싶다. +- 사용자는 타입 chip을 눌러 오디오, 시리즈, 오리지널, 무료, 포인트 목록을 전환하고 싶다. +- 사용자는 시리즈 타입에서 현재 요일의 시리즈가 기본으로 선택되어 있기를 기대한다. +- 사용자는 시리즈 타입에서 요일을 바꾸면 해당 요일 시리즈 목록이 새로 로드되기를 기대한다. +- 사용자는 정렬 메뉴에서 최신순 등 정렬 기준을 바꾸고 첫 페이지부터 다시 보고 싶다. +- 사용자는 스크롤을 내리면 다음 페이지가 자연스럽게 이어서 로드되기를 기대한다. +- 사용자는 `추천/랭킹/전체` 탭을 오가도 `전체` 탭에서 보던 필터와 목록 상태가 유지되기를 기대한다. + +## 7. Core Requirements + +### 7.1 API +- Method: `GET` +- Path: `/api/v2/audio/contents` +- 인증: 기존 V2 API 인증 헤더 패턴을 따른다. +- 응답 래퍼: 기존 관례대로 `ApiResponse` 디코딩을 우선한다. + +Query parameter: +- `type: MainContentAllType` +- `sort: ContentSort` +- `page: Int` +- `size: Int` +- `dayOfWeek: SeriesPublishedDaysOfWeek` + +기본값: +- `type = AUDIO` +- `sort = LATEST` +- `page = 0` +- `size = 20` +- `dayOfWeek = Calendar.current` 기준 현재 디바이스 요일 + +전송 조건: +- `dayOfWeek`는 `type == SERIES`일 때만 query에 포함한다. +- `type`이 `AUDIO`, `ORIGINAL`, `FREE`, `POINT`이면 `dayOfWeek`를 보내지 않는다. +- 타입, 정렬, 요일 변경 시 `page = 0`부터 다시 요청한다. +- 스크롤 페이징 요청은 현재 filter 상태를 유지한 채 `page + 1`로 요청한다. + +### 7.2 Type enum +```kotlin +enum class MainContentAllType { + AUDIO, + SERIES, + ORIGINAL, + FREE, + POINT +} +``` + +Swift 모델은 기존 enum 스타일에 맞춰 `String`, `Decodable`, `Encodable`, `CaseIterable`, `Hashable` 채택을 우선한다. + +### 7.3 Response model +```kotlin +data class MainContentAllTabResponse( + val type: MainContentAllType, + val totalCount: Int, + val audios: List, + val series: List, + val sort: ContentSort, + val dayOfWeek: SeriesPublishedDaysOfWeek?, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) + +data class MainContentAudioResponse( + val audioContentId: Long, + val title: String, + val imageUrl: String?, + val price: Int, + @JsonProperty("isAdult") + val isAdult: Boolean, + @JsonProperty("isPointAvailable") + val isPointAvailable: Boolean, + @JsonProperty("isFirstContent") + val isFirstContent: Boolean, + @JsonProperty("isOriginalSeries") + val isOriginalSeries: Boolean, + val creatorNickname: String +) + +data class MainContentSeriesResponse( + val seriesId: Long, + val title: String, + val coverImageUrl: String?, + val creatorNickname: String, + @JsonProperty("isOriginal") + val isOriginal: Boolean, + @JsonProperty("isAdult") + val isAdult: Boolean +) +``` + +Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다. `Long` 대응 id는 프로젝트 기존 모델 관례에 맞춰 Swift `Int`를 우선 사용한다. + +### 7.4 Type별 data mapping +- `AUDIO`: response `audios`를 오디오 grid로 표시한다. +- `FREE`: response `audios`를 오디오 grid로 표시한다. +- `POINT`: response `audios`를 오디오 grid로 표시한다. +- `SERIES`: response `series`를 시리즈 grid로 표시하고, 요일 필터를 노출한다. +- `ORIGINAL`: response `series`를 시리즈 grid로 표시하고, 요일 필터는 노출하지 않는다. +- API enum에 `ALL`이 없으므로 Figma의 `전체` type chip은 구현하지 않는다. +- response의 `totalCount`는 pagination 판단이나 디버깅 보조 값으로만 유지하고, 화면에는 표시하지 않는다. + +### 7.5 Paging +- `page = 0` 응답은 기존 목록을 교체한다. +- `page > 0` 응답은 현재 선택 타입에 맞는 목록 뒤에 append한다. +- `hasNext == false`이면 추가 요청을 막는다. +- 이미 다음 페이지 로딩 중이면 중복 요청을 막는다. +- 타입, 정렬, 요일 변경 시 기존 목록을 비우고 첫 페이지를 다시 요청한다. +- API 실패 시 기존 목록이 있으면 유지하고 toast 또는 error message를 표시한다. +- 첫 페이지 실패 또는 결과 없음은 empty state를 표시한다. + +### 7.6 Sort +- 기본 정렬은 `ContentSort.latest` / API 값 `LATEST`이다. +- 정렬 menu는 Figma처럼 sort bar 우측에 표시한다. +- 정렬 변경 시 첫 페이지부터 다시 요청한다. +- `추천순`은 구현하지 않는다. +- `FREE` 타입에서는 `최신순`, `인기순`만 제공한다. +- `AUDIO`, `SERIES`, `ORIGINAL`, `POINT` 타입에서 제공할 sort 후보: + - `LATEST` + - `POPULAR` + - `PRICE_HIGH` + - `PRICE_LOW` +- `FREE` 타입에서 제공할 sort 후보: + - `LATEST` + - `POPULAR` +- 현재 선택된 sort가 타입 변경 후 허용되지 않는 값이면 `LATEST`로 초기화하고 첫 페이지부터 다시 요청한다. + +### 7.7 Day of week +- `SeriesPublishedDaysOfWeek` 기존 enum을 사용한다. +- 현재 디바이스 요일 기본값은 `Calendar.current.component(.weekday, from: Date())`를 기준으로 매핑한다. + - Sunday: `.SUN` + - Monday: `.MON` + - Tuesday: `.TUE` + - Wednesday: `.WED` + - Thursday: `.THU` + - Friday: `.FRI` + - Saturday: `.SAT` +- `RANDOM`은 Figma의 `기타` 표시와 매핑한다. +- 요일 필터는 `SERIES` 타입에서만 노출한다. +- `ORIGINAL` 타입은 response `series`를 쓰지만 `dayOfWeek`를 보내지 않고 요일 필터도 노출하지 않는다. + +짧은 요일 다국어 표기: +- `MON`: ko `월`, en `MON`, ja `月` +- `TUE`: ko `화`, en `TUE`, ja `火` +- `WED`: ko `수`, en `WED`, ja `水` +- `THU`: ko `목`, en `THU`, ja `木` +- `FRI`: ko `금`, en `FRI`, ja `金` +- `SAT`: ko `토`, en `SAT`, ja `土` +- `SUN`: ko `일`, en `SUN`, ja `日` +- `RANDOM`: ko `기타`, en `OTHER`, ja `その他` + +### 7.8 UI layout +- 내부 tab bar에서 `전체`가 선택된 상태로 표시된다. +- 콘텐츠 타입 chip은 Figma 기준 수평 스크롤 형태를 따른다. +- 콘텐츠 타입 chip은 `오디오`, `시리즈`, `오리지널`, `무료`, `포인트`만 표시한다. +- Figma에 보이는 `전체` chip은 표시하지 않는다. +- 콘텐츠 타입 chip 기본 선택은 `오디오`이다. +- sort bar는 좌측에 현재 범위 label, 우측에 현재 sort label과 펼침 아이콘을 둔다. +- sort menu에는 `추천순`을 표시하지 않는다. +- 오디오/무료/포인트 grid는 Figma 오디오 상태와 동일한 3열 정사각형 썸네일 grid를 따른다. +- 시리즈/오리지널 grid는 Figma 시리즈/오리지널 상태와 동일한 3열 세로형 썸네일 grid를 따른다. +- response의 `totalCount` 또는 전체 개수 문구는 표시하지 않는다. +- 하단 main tab bar와 mini player 영역은 기존 `MainView` 구조를 유지한다. +- 빈 목록, 로딩, pagination loading 상태는 기존 V2 콘텐츠/추천 탭 패턴을 따른다. + +### 7.9 Tab state preservation +- 내부 `추천/랭킹/전체` 탭 전환 시 `전체` 탭 ViewModel 상태를 유지한다. +- 유지 대상은 선택된 콘텐츠 타입, sort, dayOfWeek, 현재 page, hasNext, 로드된 `audios`/`series` 목록이다. +- `전체` 탭으로 돌아왔을 때 사용자가 명시적으로 filter를 변경하지 않았다면 첫 페이지를 자동 재요청하지 않는다. + +### 7.10 Navigation +- 오디오 item 탭은 `audioContentId`로 기존 content detail 진입 흐름을 사용한다. +- 시리즈 item 탭은 `seriesId`로 기존 series detail 진입 흐름을 사용한다. +- 로그인/성인 콘텐츠 guard가 기존 상세 진입 흐름에 있다면 재사용한다. + +## 8. UX / UI Expectations +- 전체 배경은 기존 V2 dark UI 기준을 따른다. +- 신규 사용자 노출 문자열은 `I18n`에 ko/en/ja로 추가한다. +- Figma localhost asset URL은 앱 코드에 사용하지 않는다. +- 타입 chip의 `전체`와 sort menu의 `추천순`은 표시하지 않는다. +- 전체 개수 또는 `totalCount`는 표시하지 않는다. +- 오디오 카드의 title은 `title`, subtitle은 `creatorNickname`을 표시한다. +- 시리즈 카드의 title은 `title`, subtitle은 `creatorNickname`을 표시한다. +- `MainContentAudioResponse.isOriginalSeries == true`이면 오디오 original tag를 표시한다. +- `MainContentAudioResponse.isFirstContent == true`이면 first tag를 표시한다. +- `MainContentAudioResponse.isPointAvailable == true`이면 point tag를 표시한다. +- `MainContentAudioResponse.price == 0`이면 free tag를 표시한다. +- `isAdult == true`이면 adult shield tag를 표시한다. +- `MainContentSeriesResponse.isOriginal == true`이면 `ic_series_original` + `img_new_only` 조합의 `ONLY` tag를 표시한다. +- 텍스트가 길면 Figma 기준 line limit로 말줄임 처리한다. + +## 9. 재사용 가능한 V2 위젯 후보 +- `SodaLive/Sources/V2/Main/Content/MainContentView.swift` + - 콘텐츠 탭 root와 title bar, 내부 tab bar 구조를 확장할 후보. +- `SodaLive/Sources/V2/Main/Content/MainContentTab.swift` + - 내부 `전체` 탭 case 추가 후보. +- `SodaLive/Sources/V2/Component/HomeTitleBar.swift` + - 콘텐츠 탭 title bar 재사용 후보. +- `SodaLive/Sources/V2/Component/TextTabBar.swift` + - 내부 `추천/랭킹/전체` tab bar 재사용 후보. +- `SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift` + - `AUDIO`, `FREE`, `POINT` 타입 카드의 1차 재사용 후보. + - `AudioContentThumbnailCardItem`에 `MainContentAudioResponse` adapter init 추가를 검토한다. +- `SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesListItem.swift` + - `ic_series_original` + `img_new_only` tag와 세로형 시리즈 썸네일 스타일 참고 후보. + - 현재 `CreatorChannelSeriesResponse` 전용이고 요일/진행/소장률 필드를 요구하므로 그대로 재사용하기보다 `MainContentSeriesResponse` 전용 thumbnail card 또는 adapter를 우선 검토한다. +- `SodaLive/Sources/V2/CreatorChannel/Models/ContentSort.swift` + - `sort` query와 sort menu label 재사용 후보. +- `SodaLive/Sources/Home/HomeTabViewModel.swift` + - 기존 `SeriesPublishedDaysOfWeek` enum 위치 확인 대상. +- `SodaLive/Sources/Content/Series/Main/DayOfWeek/SeriesMainDayOfWeekView.swift` + - 현재 디바이스 요일 매핑과 요일 selector 구현 참고 대상. + +## 10. Technical Constraints +- 신규 구현이 이어질 경우 기능 변경은 `SodaLive/Sources/V2/Main/Content/**` 하위에서 해결한다. +- API, Repository, ViewModel, Model은 내부 전체 탭 전용 경로로 분리한다. +- 여러 화면에서 재사용할 명확한 근거가 있는 UI만 `SodaLive/Sources/V2/Component/**`로 승격한다. +- 기존 legacy `SodaLive/Sources/Content/**` 화면을 리팩터링하지 않는다. +- 기존 `MainContentRecommendation` API/ViewModel을 직접 확장하지 않는다. +- 외부 라이브러리는 추가하지 않는다. +- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. + +## 11. Success Criteria +- 콘텐츠 탭 내부 tab bar에 `전체` 탭이 추가되고 선택 가능하다. +- 내부 `전체` 탭 최초 진입 시 `GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20` 요청이 발생한다. +- 최초 진입 요청에는 `dayOfWeek`가 포함되지 않는다. +- `SERIES` 타입 선택 시 현재 디바이스 요일이 선택되고, `dayOfWeek` query가 포함된다. +- `SERIES` 외 타입 요청에는 `dayOfWeek`가 포함되지 않는다. +- `AUDIO`, `FREE`, `POINT` 선택 시 response `audios`가 `AudioContentThumbnailCard` 기반 grid로 표시된다. +- `SERIES`, `ORIGINAL` 선택 시 response `series`가 세로형 시리즈 grid로 표시된다. +- Figma에 보이는 타입 chip `전체`는 표시되지 않는다. +- sort menu에 `추천순`이 표시되지 않는다. +- `FREE` 타입의 sort menu에는 `최신순`, `인기순`만 표시된다. +- response의 `totalCount` 또는 전체 개수 문구가 표시되지 않는다. +- 내부 `추천/랭킹/전체` 탭 전환 후 `전체` 탭으로 돌아오면 선택 타입, 정렬, 요일, 로드된 목록이 유지된다. +- `hasNext == true`인 상태에서 목록 하단 도달 시 다음 page가 요청되고 기존 목록 뒤에 append된다. +- 타입, 정렬, 요일 변경 시 page가 0으로 초기화되고 목록이 새 응답으로 교체된다. +- `기타` 요일은 영어에서 `OTHER`, 일본어에서 `その他`로 표시된다. +- 오디오 item 탭 시 오디오 상세로 이동하고, 시리즈 item 탭 시 시리즈 상세로 이동한다. + +## 12. Open Questions +- 현재 확정된 요구사항 기준으로 미결정 사항은 없다. + +## 13. Decisions +- Figma 타입 chip의 `전체`는 구현 대상에서 제외한다. +- Figma sort menu의 `추천순`은 구현하지 않는다. +- 내부 `추천/랭킹/전체` 탭 전환 시 `전체` 탭 상태를 유지한다. +- `FREE` 타입의 sort menu는 `LATEST`, `POPULAR`만 제공한다. +- response의 `totalCount`는 UI에 표시하지 않는다. + +## 14. Verification Notes +- 2026-07-06: `docs/agent-guides/documentation-policy.md`를 확인해 PRD-only 작업은 필요한 최소 문서만 작성해도 됨을 확인했다. +- 2026-07-06: 기존 `docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md`, `plan-task.md`를 확인해 이번 작업이 추천 탭이 아닌 내부 전체 탭 별도 범위임을 확인했다. +- 2026-07-06: Figma `get_design_context`와 `get_screenshot`으로 `35:5857`, `24:6909`, `24:9105`를 확인했다. +- 2026-07-06: `AudioContentThumbnailCard`, `CreatorChannelSeriesListItem`, `ContentSort`, `SeriesPublishedDaysOfWeek`, `SeriesMainDayOfWeekView`, `I18n.Series`를 확인해 재사용 후보와 제약을 정리했다. +- 2026-07-06: 사용자 결정 사항을 반영해 `전체` chip 제외, `추천순` 제외, 탭 전환 상태 유지, `FREE` sort 제한, `totalCount` 미표시를 확정했다.