diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 08ca773d..01c0cc82 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -1700,6 +1700,36 @@ C0A706000000000000000001 /* AudioContentThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A706000000000000000000 /* AudioContentThumbnailCard.swift */; }; C0A706000000000000000002 /* AudioContentThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A706000000000000000000 /* AudioContentThumbnailCard.swift */; }; C0A706000000000000000003 /* AudioContentThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A706000000000000000000 /* AudioContentThumbnailCard.swift */; }; + C0A721000000000000000001 /* AudioRankingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000001 /* AudioRankingResponse.swift */; }; + C0A721000000000000000002 /* MainContentRankingApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000002 /* MainContentRankingApi.swift */; }; + C0A721000000000000000003 /* MainContentRankingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000003 /* MainContentRankingRepository.swift */; }; + C0A721000000000000000004 /* MainContentRankingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000004 /* MainContentRankingViewModel.swift */; }; + C0A721000000000000000005 /* MainContentRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000005 /* MainContentRankingView.swift */; }; + C0A721000000000000000006 /* MainContentRankChangeBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000006 /* MainContentRankChangeBadge.swift */; }; + C0A721000000000000000007 /* MainContentAudioRankingTopCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000007 /* MainContentAudioRankingTopCard.swift */; }; + C0A721000000000000000008 /* MainContentAudioRankingRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000008 /* MainContentAudioRankingRow.swift */; }; + C0A721000000000000000009 /* MainContentAudioRankingEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000009 /* MainContentAudioRankingEmptyStateView.swift */; }; + C0A72100000000000000000A /* MainContentAudioRankingThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A72000000000000000000A /* MainContentAudioRankingThumbnailCard.swift */; }; + C0A722000000000000000001 /* AudioRankingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000001 /* AudioRankingResponse.swift */; }; + C0A722000000000000000002 /* MainContentRankingApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000002 /* MainContentRankingApi.swift */; }; + C0A722000000000000000003 /* MainContentRankingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000003 /* MainContentRankingRepository.swift */; }; + C0A722000000000000000004 /* MainContentRankingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000004 /* MainContentRankingViewModel.swift */; }; + C0A722000000000000000005 /* MainContentRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000005 /* MainContentRankingView.swift */; }; + C0A722000000000000000006 /* MainContentRankChangeBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000006 /* MainContentRankChangeBadge.swift */; }; + C0A722000000000000000007 /* MainContentAudioRankingTopCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000007 /* MainContentAudioRankingTopCard.swift */; }; + C0A722000000000000000008 /* MainContentAudioRankingRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000008 /* MainContentAudioRankingRow.swift */; }; + C0A722000000000000000009 /* MainContentAudioRankingEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000009 /* MainContentAudioRankingEmptyStateView.swift */; }; + C0A72200000000000000000A /* MainContentAudioRankingThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A72000000000000000000A /* MainContentAudioRankingThumbnailCard.swift */; }; + C0A723000000000000000001 /* AudioRankingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000001 /* AudioRankingResponse.swift */; }; + C0A723000000000000000002 /* MainContentRankingApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000002 /* MainContentRankingApi.swift */; }; + C0A723000000000000000003 /* MainContentRankingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000003 /* MainContentRankingRepository.swift */; }; + C0A723000000000000000004 /* MainContentRankingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000004 /* MainContentRankingViewModel.swift */; }; + C0A723000000000000000005 /* MainContentRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000005 /* MainContentRankingView.swift */; }; + C0A723000000000000000006 /* MainContentRankChangeBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000006 /* MainContentRankChangeBadge.swift */; }; + C0A723000000000000000007 /* MainContentAudioRankingTopCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000007 /* MainContentAudioRankingTopCard.swift */; }; + C0A723000000000000000008 /* MainContentAudioRankingRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000008 /* MainContentAudioRankingRow.swift */; }; + C0A723000000000000000009 /* MainContentAudioRankingEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A720000000000000000009 /* MainContentAudioRankingEmptyStateView.swift */; }; + C0A72300000000000000000A /* MainContentAudioRankingThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A72000000000000000000A /* MainContentAudioRankingThumbnailCard.swift */; }; 7B2EE8B4132668F0267B10CB /* UserProfileChannelDonationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */; }; 7BED9B1CFDC729EC4AF42B35 /* Radius.swift in Sources */ = {isa = PBXBuildFile; fileRef = E31B21FA615404F2E9E7839F /* Radius.swift */; }; 80209D5731C339163084890C /* LanguageSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 716260E8214A329EE07CAAFA /* LanguageSettingsViewModel.swift */; }; @@ -2116,6 +2146,16 @@ C0A70000000000000000000C /* MainContentMostCommentedAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentMostCommentedAudioSection.swift; sourceTree = ""; }; C0A70000000000000000000D /* MainContentRecommendedAudioGridSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendedAudioGridSection.swift; sourceTree = ""; }; C0A70000000000000000000F /* MainContentAudioEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioEmptyStateView.swift; sourceTree = ""; }; + C0A720000000000000000001 /* AudioRankingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRankingResponse.swift; sourceTree = ""; }; + C0A720000000000000000002 /* MainContentRankingApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRankingApi.swift; sourceTree = ""; }; + C0A720000000000000000003 /* MainContentRankingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRankingRepository.swift; sourceTree = ""; }; + C0A720000000000000000004 /* MainContentRankingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRankingViewModel.swift; sourceTree = ""; }; + C0A720000000000000000005 /* MainContentRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRankingView.swift; sourceTree = ""; }; + C0A720000000000000000006 /* MainContentRankChangeBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRankChangeBadge.swift; sourceTree = ""; }; + C0A720000000000000000007 /* MainContentAudioRankingTopCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioRankingTopCard.swift; sourceTree = ""; }; + C0A720000000000000000008 /* MainContentAudioRankingRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioRankingRow.swift; sourceTree = ""; }; + C0A720000000000000000009 /* MainContentAudioRankingEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioRankingEmptyStateView.swift; sourceTree = ""; }; + C0A72000000000000000000A /* MainContentAudioRankingThumbnailCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioRankingThumbnailCard.swift; sourceTree = ""; }; 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeLiveSection.swift; sourceTree = ""; }; 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeBannerSection.swift; sourceTree = ""; }; 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeActiveCreatorSection.swift; sourceTree = ""; }; @@ -3220,6 +3260,7 @@ isa = PBXGroup; children = ( C0A700000000000000000102 /* Recommendation */, + C0A720000000000000000100 /* Ranking */, C0A710000000000000000100 /* All */, C0A700000000000000000001 /* MainContentView.swift */, C0A700000000000000000002 /* MainContentTab.swift */, @@ -3270,6 +3311,47 @@ path = Components; sourceTree = ""; }; + C0A720000000000000000100 /* Ranking */ = { + isa = PBXGroup; + children = ( + C0A720000000000000000103 /* Components */, + C0A720000000000000000101 /* Models */, + C0A720000000000000000102 /* Repository */, + C0A720000000000000000004 /* MainContentRankingViewModel.swift */, + C0A720000000000000000005 /* MainContentRankingView.swift */, + ); + path = Ranking; + sourceTree = ""; + }; + C0A720000000000000000101 /* Models */ = { + isa = PBXGroup; + children = ( + C0A720000000000000000001 /* AudioRankingResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + C0A720000000000000000102 /* Repository */ = { + isa = PBXGroup; + children = ( + C0A720000000000000000002 /* MainContentRankingApi.swift */, + C0A720000000000000000003 /* MainContentRankingRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; + C0A720000000000000000103 /* Components */ = { + isa = PBXGroup; + children = ( + C0A720000000000000000006 /* MainContentRankChangeBadge.swift */, + C0A720000000000000000007 /* MainContentAudioRankingTopCard.swift */, + C0A720000000000000000008 /* MainContentAudioRankingRow.swift */, + C0A72000000000000000000A /* MainContentAudioRankingThumbnailCard.swift */, + C0A720000000000000000009 /* MainContentAudioRankingEmptyStateView.swift */, + ); + path = Components; + sourceTree = ""; + }; 401A3F992FEEAFA000A5BBA3 /* Models */ = { isa = PBXGroup; children = ( @@ -6416,6 +6498,16 @@ C0A70100000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */, C0A70100000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */, C0A70100000000000000000F /* MainContentAudioEmptyStateView.swift in Sources */, + C0A721000000000000000001 /* AudioRankingResponse.swift in Sources */, + C0A721000000000000000002 /* MainContentRankingApi.swift in Sources */, + C0A721000000000000000003 /* MainContentRankingRepository.swift in Sources */, + C0A721000000000000000004 /* MainContentRankingViewModel.swift in Sources */, + C0A721000000000000000005 /* MainContentRankingView.swift in Sources */, + C0A721000000000000000006 /* MainContentRankChangeBadge.swift in Sources */, + C0A721000000000000000007 /* MainContentAudioRankingTopCard.swift in Sources */, + C0A721000000000000000008 /* MainContentAudioRankingRow.swift in Sources */, + C0A72100000000000000000A /* MainContentAudioRankingThumbnailCard.swift in Sources */, + C0A721000000000000000009 /* MainContentAudioRankingEmptyStateView.swift in Sources */, C0A706000000000000000003 /* AudioContentThumbnailCard.swift in Sources */, 401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, @@ -7278,6 +7370,16 @@ C0A70200000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */, C0A70200000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */, C0A70200000000000000000F /* MainContentAudioEmptyStateView.swift in Sources */, + C0A722000000000000000001 /* AudioRankingResponse.swift in Sources */, + C0A722000000000000000002 /* MainContentRankingApi.swift in Sources */, + C0A722000000000000000003 /* MainContentRankingRepository.swift in Sources */, + C0A722000000000000000004 /* MainContentRankingViewModel.swift in Sources */, + C0A722000000000000000005 /* MainContentRankingView.swift in Sources */, + C0A722000000000000000006 /* MainContentRankChangeBadge.swift in Sources */, + C0A722000000000000000007 /* MainContentAudioRankingTopCard.swift in Sources */, + C0A722000000000000000008 /* MainContentAudioRankingRow.swift in Sources */, + C0A72200000000000000000A /* MainContentAudioRankingThumbnailCard.swift in Sources */, + C0A722000000000000000009 /* MainContentAudioRankingEmptyStateView.swift in Sources */, C0A706000000000000000001 /* AudioContentThumbnailCard.swift in Sources */, 401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, @@ -8180,6 +8282,16 @@ C0A70300000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */, C0A70300000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */, C0A70300000000000000000F /* MainContentAudioEmptyStateView.swift in Sources */, + C0A723000000000000000001 /* AudioRankingResponse.swift in Sources */, + C0A723000000000000000002 /* MainContentRankingApi.swift in Sources */, + C0A723000000000000000003 /* MainContentRankingRepository.swift in Sources */, + C0A723000000000000000004 /* MainContentRankingViewModel.swift in Sources */, + C0A723000000000000000005 /* MainContentRankingView.swift in Sources */, + C0A723000000000000000006 /* MainContentRankChangeBadge.swift in Sources */, + C0A723000000000000000007 /* MainContentAudioRankingTopCard.swift in Sources */, + C0A723000000000000000008 /* MainContentAudioRankingRow.swift in Sources */, + C0A72300000000000000000A /* MainContentAudioRankingThumbnailCard.swift in Sources */, + C0A723000000000000000009 /* MainContentAudioRankingEmptyStateView.swift in Sources */, C0A706000000000000000002 /* AudioContentThumbnailCard.swift in Sources */, 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 70caf13d..ceea5318 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -3053,6 +3053,22 @@ If you block this user, the following features will be restricted. static var tabTitle: String { pick(ko: "랭킹", en: "Ranking", ja: "ランキング") } + + static var emptyStateMessage: String { + pick( + ko: "랭킹 콘텐츠가 아직 없어요.", + en: "No ranking content yet.", + ja: "ランキングコンテンツはまだありません。" + ) + } + + static var loadFailedMessage: String { + pick( + ko: "랭킹 콘텐츠를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.", + en: "Unable to load ranking content.\nPlease try again later.", + ja: "ランキングコンテンツを読み込めませんでした。\nしばらくしてからもう一度お試しください。" + ) + } } enum MainContentAll { diff --git a/SodaLive/Sources/V2/Main/Content/MainContentView.swift b/SodaLive/Sources/V2/Main/Content/MainContentView.swift index 507e94e3..bbb10c65 100644 --- a/SodaLive/Sources/V2/Main/Content/MainContentView.swift +++ b/SodaLive/Sources/V2/Main/Content/MainContentView.swift @@ -48,7 +48,7 @@ struct MainContentView: View { onTapSeries: onTapSeries ) case .ranking: - MainPlaceholderTabView(title: MainContentTab.ranking.title) + MainContentRankingView(onTapContent: onTapContent) case .all: MainContentAllView( viewModel: allViewModel, diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingEmptyStateView.swift b/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingEmptyStateView.swift new file mode 100644 index 00000000..0002a229 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingEmptyStateView.swift @@ -0,0 +1,10 @@ +import SwiftUI + +struct MainContentAudioRankingEmptyStateView: View { + let message: String + + var body: some View { + MainContentAudioEmptyStateView(message: message) + .background(Color.black) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingRow.swift b/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingRow.swift new file mode 100644 index 00000000..b4a076fb --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingRow.swift @@ -0,0 +1,75 @@ +import SwiftUI + +struct MainContentAudioRankingRow: View { + let item: AudioRankingItemResponse + let showRankChange: Bool + let onTapContent: (Int) -> Void + + var body: some View { + Button { + onTapContent(item.contentId) + } label: { + GeometryReader { proxy in + let rowHeight = proxy.size.height + let imageSide = rowHeight * 0.8 + + HStack(alignment: .center, spacing: SodaSpacing.s14) { + rankColumn + + DownsampledKFImage( + url: URL(string: item.coverImageUrl ?? ""), + size: CGSize(width: imageSide, height: imageSide) + ) + .background(Color.gray800) + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + + VStack(alignment: .leading, spacing: SodaSpacing.s4) { + Text(item.title) + .appFont(.heading4) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(item.creatorNickname) + .appFont(.body4) + .foregroundColor(Color.gray400) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, SodaSpacing.s14) + .padding(.vertical, rowHeight * 0.1) + } + .aspectRatio(3.74, contentMode: .fit) + .background(Color.black) + } + .buttonStyle(.plain) + } + + private var rankColumn: some View { + VStack(alignment: .center, spacing: 0) { + Text("\(item.rank)") + .appFont(size: 40, family: .pattaya) + .foregroundStyle( + LinearGradient( + colors: [.white, Color.gray200], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: Color.black.opacity(0.48), radius: 2) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .center) + + MainContentRankChangeBadge( + showRankChange: showRankChange, + rankChange: item.rankChange, + isNew: item.isNew + ) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(width: 49, alignment: .center) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingThumbnailCard.swift b/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingThumbnailCard.swift new file mode 100644 index 00000000..3dafee99 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingThumbnailCard.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct MainContentAudioRankingThumbnailCard: View { + let item: AudioRankingItemResponse + let showRankChange: Bool + let columnsPerRow: Int + let onTapContent: (Int) -> Void + + init( + item: AudioRankingItemResponse, + showRankChange: Bool, + columnsPerRow: Int, + onTapContent: @escaping (Int) -> Void = { _ in } + ) { + self.item = item + self.showRankChange = showRankChange + self.columnsPerRow = columnsPerRow + self.onTapContent = onTapContent + } + + var body: some View { + Button { + onTapContent(item.contentId) + } label: { + GeometryReader { proxy in + let side = min(proxy.size.width, proxy.size.height) + + ZStack(alignment: .topLeading) { + coverImage(size: CGSize(width: side, height: side)) + + LinearGradient( + colors: [Color.black.opacity(0), Color.black.opacity(0.72)], + startPoint: .top, + endPoint: .bottom + ) + + rankColumn(side: side) + .padding(.leading, rankColumnLeadingPadding(side)) + .padding(.top, rankColumnTopPadding(side)) + + VStack(spacing: SodaSpacing.s4) { + Spacer() + + Text(item.title) + .appFont(size: titleFontSize(side), weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(item.creatorNickname) + .appFont(size: creatorFontSize(side), weight: .regular) + .foregroundColor(Color.gray200) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, textHorizontalPadding(side)) + .padding(.bottom, textBottomPadding(side)) + } + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + .aspectRatio(1, contentMode: .fit) + } + .buttonStyle(.plain) + } + + private func coverImage(size: CGSize) -> some View { + DownsampledKFImage(url: URL(string: item.coverImageUrl ?? ""), size: size) + .background(Color.gray800) + } + + private func rankColumn(side: CGFloat) -> some View { + VStack(alignment: .center, spacing: rankColumnSpacing) { + Text("\(item.rank)") + .appFont(size: rankFontSize(side), family: .pattaya) + .foregroundStyle( + LinearGradient( + colors: [.white, Color.gray200], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: Color.black.opacity(0.48), radius: 2) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .center) + + MainContentRankChangeBadge( + showRankChange: showRankChange, + rankChange: item.rankChange, + isNew: item.isNew + ) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(width: rankColumnWidth(side), alignment: .center) + } + + private var rankColumnSpacing: CGFloat { + switch columnsPerRow { + case 2: + return -10 + case 3: + return -5 + default: + return -10 + } + } + + private func rankFontSize(_ side: CGFloat) -> CGFloat { + max(32, side * 0.26) + } + + private func titleFontSize(_ side: CGFloat) -> CGFloat { + max(14, side * 0.085) + } + + private func creatorFontSize(_ side: CGFloat) -> CGFloat { + max(12, side * 0.07) + } + + private func rankColumnWidth(_ side: CGFloat) -> CGFloat { + max(49, side * 0.19) + } + + private func rankColumnLeadingPadding(_ side: CGFloat) -> CGFloat { + max(0, side * 0.035) + } + + private func rankColumnTopPadding(_ side: CGFloat) -> CGFloat { + max(-8, side * 0.02) + } + + private func textHorizontalPadding(_ side: CGFloat) -> CGFloat { + max(SodaSpacing.s8, side * 0.08) + } + + private func textBottomPadding(_ side: CGFloat) -> CGFloat { + max(SodaSpacing.s12, side * 0.08) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingTopCard.swift b/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingTopCard.swift new file mode 100644 index 00000000..f9b201a9 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingTopCard.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct MainContentAudioRankingTopCard: View { + let item: AudioRankingItemResponse + let showRankChange: Bool + let onTapContent: (Int) -> Void + + var body: some View { + Button { + onTapContent(item.contentId) + } label: { + GeometryReader { proxy in + let width = proxy.size.width + let imageSide = min(width * 0.42, proxy.size.height * 0.65) + + ZStack(alignment: .topLeading) { + DownsampledKFImage( + url: URL(string: item.coverImageUrl ?? ""), + size: CGSize(width: width, height: proxy.size.height) + ) + .background(Color.gray800) + .blur(radius: 10) + + Color.black.opacity(0.1) + + LinearGradient( + colors: [Color.black.opacity(0), Color.black.opacity(0.72)], + startPoint: .top, + endPoint: .bottom + ) + + rankColumn + .padding(.leading, SodaSpacing.s20) + .padding(.top, SodaSpacing.s4) + + VStack(spacing: SodaSpacing.s8) { + DownsampledKFImage( + url: URL(string: item.coverImageUrl ?? ""), + size: CGSize(width: imageSide, height: imageSide) + ) + .background(Color.gray800) + .aspectRatio(1, contentMode: .fill) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + .shadow(color: Color.black.opacity(0.2), radius: 15) + + VStack(spacing: 0) { + Text(item.title) + .appFont(size: 22, weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(item.creatorNickname) + .appFont(.caption1) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity) + } + .padding(.horizontal, SodaSpacing.s20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + .aspectRatio(1.57, contentMode: .fit) + } + .buttonStyle(.plain) + } + + private var rankColumn: some View { + VStack(alignment: .center, spacing: -20) { + Text("\(item.rank)") + .appFont(size: 96, family: .pattaya) + .foregroundStyle( + LinearGradient( + colors: [.white, Color.gray200], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: Color.black.opacity(0.48), radius: 2) + .lineLimit(1) + + MainContentRankChangeBadge( + showRankChange: showRankChange, + rankChange: item.rankChange, + isNew: item.isNew + ) + } + .frame(width: 86, alignment: .center) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentRankChangeBadge.swift b/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentRankChangeBadge.swift new file mode 100644 index 00000000..56cba46d --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentRankChangeBadge.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct MainContentRankChangeBadge: View { + let showRankChange: Bool + let rankChange: Int? + let isNew: Bool + + var body: some View { + if showRankChange { + if isNew { + Image("ic_rank_new") + .resizable() + .scaledToFit() + .frame(width: 28, height: 12) + } else if let rankChange { + HStack(spacing: 2) { + switch rankChange { + case let value where value > 0: + rankChangeText(abs(value)) + rankChangeIcon("ic_rank_caret_increase") + case let value where value < 0: + rankChangeText(abs(value)) + rankChangeIcon("ic_rank_caret_decrease") + default: + rankChangeIcon("ic_rank_caret_stay") + } + } + .padding(.horizontal, 4) + .background(Color.gray900) + .cornerRadius(4) + } + } + } + + private func rankChangeText(_ value: Int) -> some View { + Text("\(value)") + .appFont(.caption2) + .foregroundColor(Color.gray400) + .lineLimit(1) + } + + private func rankChangeIcon(_ name: String) -> some View { + Image(name) + .resizable() + .scaledToFit() + .frame(width: 8, height: 8) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingView.swift b/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingView.swift new file mode 100644 index 00000000..c962e41d --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +struct MainContentRankingView: View { + let onTapContent: (Int) -> Void + + @StateObject private var viewModel = MainContentRankingViewModel() + + init(onTapContent: @escaping (Int) -> Void = { _ in }) { + self.onTapContent = onTapContent + } + + var body: some View { + ZStack { + Color.black + .ignoresSafeArea() + + if viewModel.isLoading && !viewModel.hasLoaded { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else if viewModel.shouldShowEmptyState { + MainContentAudioRankingEmptyStateView(message: viewModel.emptyStateMessage) + } else { + rankingContent + } + } + .onAppear { + if !viewModel.hasLoaded { + viewModel.fetchRankings() + } + } + .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) + } + + private var rankingContent: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: SodaSpacing.s8) { + if let topItem { + MainContentAudioRankingTopCard( + item: topItem, + showRankChange: viewModel.showRankChange, + onTapContent: onTapContent + ) + } + + if !secondToSeventhRankItems.isEmpty { + LazyVGrid(columns: twoColumns, alignment: .center, spacing: SodaSpacing.s8) { + ForEach(secondToSeventhRankItems) { item in + MainContentAudioRankingThumbnailCard( + item: item, + showRankChange: viewModel.showRankChange, + columnsPerRow: 2, + onTapContent: onTapContent + ) + } + } + } + + if !eighthToTenthRankItems.isEmpty { + LazyVGrid(columns: threeColumns, alignment: .center, spacing: SodaSpacing.s8) { + ForEach(eighthToTenthRankItems) { item in + MainContentAudioRankingThumbnailCard( + item: item, + showRankChange: viewModel.showRankChange, + columnsPerRow: 3, + onTapContent: onTapContent + ) + } + } + } + + if !rowRankItems.isEmpty { + VStack(spacing: SodaSpacing.s8) { + ForEach(rowRankItems) { item in + MainContentAudioRankingRow( + item: item, + showRankChange: viewModel.showRankChange, + onTapContent: onTapContent + ) + } + } + } + } + .padding(.horizontal, SodaSpacing.s8) + .padding(.vertical, SodaSpacing.s20) + } + } + + private var twoColumns: [GridItem] { + [GridItem(.flexible()), GridItem(.flexible())] + } + + private var threeColumns: [GridItem] { + [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] + } + + private var topItem: AudioRankingItemResponse? { + viewModel.items.first { $0.rank == 1 } + } + + private var secondToSeventhRankItems: [AudioRankingItemResponse] { + viewModel.items.filter { 2...7 ~= $0.rank } + } + + private var eighthToTenthRankItems: [AudioRankingItemResponse] { + viewModel.items.filter { 8...10 ~= $0.rank } + } + + private var rowRankItems: [AudioRankingItemResponse] { + viewModel.items.filter { 11...20 ~= $0.rank } + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingViewModel.swift b/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingViewModel.swift new file mode 100644 index 00000000..27988421 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingViewModel.swift @@ -0,0 +1,80 @@ +import Foundation +import Combine + +final class MainContentRankingViewModel: ObservableObject { + private let repository = MainContentRankingRepository() + private var subscription = Set() + + @Published var isLoading = false + @Published var isShowPopup = false + @Published var errorMessage = "" + @Published var items = [AudioRankingItemResponse]() + @Published var showRankChange = false + @Published var hasLoaded = false + + var emptyStateMessage: String { + if !errorMessage.isEmpty { + return errorMessage + } + + return I18n.MainContentRanking.emptyStateMessage + } + + var shouldShowEmptyState: Bool { + hasLoaded && !isLoading && items.isEmpty + } + + func fetchRankings() { + guard !isLoading else { return } + + isLoading = true + + repository.getAudioRankings() + .sink { [weak self] result in + guard let self else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self.items = [] + self.showRankChange = false + self.errorMessage = I18n.MainContentRanking.loadFailedMessage + self.isShowPopup = true + self.isLoading = false + self.hasLoaded = true + } + } receiveValue: { [weak self] response in + guard let self else { return } + + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success, !data.items.isEmpty { + self.errorMessage = "" + self.items = Array(data.items.prefix(20)) + self.showRankChange = data.showRankChange + } else { + self.items = [] + self.showRankChange = false + self.errorMessage = decoded.success ? "" : I18n.MainContentRanking.loadFailedMessage + self.isShowPopup = !decoded.success + } + } catch { + ERROR_LOG(error.localizedDescription) + self.items = [] + self.showRankChange = false + self.errorMessage = I18n.MainContentRanking.loadFailedMessage + self.isShowPopup = true + } + + self.isLoading = false + self.hasLoaded = true + } + .store(in: &subscription) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/Models/AudioRankingResponse.swift b/SodaLive/Sources/V2/Main/Content/Ranking/Models/AudioRankingResponse.swift new file mode 100644 index 00000000..f3832a33 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Ranking/Models/AudioRankingResponse.swift @@ -0,0 +1,28 @@ +import Foundation + +struct AudioRankingResponse: Decodable { + let showRankChange: Bool + let type: AudioRankingType + let items: [AudioRankingItemResponse] +} + +enum AudioRankingType: String, Decodable, CaseIterable, Hashable { + case weeklyPopular = "WEEKLY_POPULAR" + case rising = "RISING" + case revenue = "REVENUE" + case salesCount = "SALES_COUNT" + case commentCount = "COMMENT_COUNT" + case likeCount = "LIKE_COUNT" +} + +struct AudioRankingItemResponse: Decodable, Identifiable { + let contentId: Int + let title: String + let creatorNickname: String + let rank: Int + let rankChange: Int? + let isNew: Bool + let coverImageUrl: String? + + var id: Int { contentId } +} diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/Repository/MainContentRankingApi.swift b/SodaLive/Sources/V2/Main/Content/Ranking/Repository/MainContentRankingApi.swift new file mode 100644 index 00000000..b1396d4d --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Ranking/Repository/MainContentRankingApi.swift @@ -0,0 +1,38 @@ +import Foundation + +import Moya + +enum MainContentRankingApi { + case getAudioRankings +} + +extension MainContentRankingApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getAudioRankings: + return "/api/v2/audio/rankings" + } + } + + var method: Moya.Method { + switch self { + case .getAudioRankings: + return .get + } + } + + var task: Moya.Task { + switch self { + case .getAudioRankings: + return .requestPlain + } + } + + var headers: [String: String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Ranking/Repository/MainContentRankingRepository.swift b/SodaLive/Sources/V2/Main/Content/Ranking/Repository/MainContentRankingRepository.swift new file mode 100644 index 00000000..0ea34d37 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Ranking/Repository/MainContentRankingRepository.swift @@ -0,0 +1,13 @@ +import Foundation +import Combine + +import CombineMoya +import Moya + +final class MainContentRankingRepository { + private let api = MoyaProvider() + + func getAudioRankings() -> AnyPublisher { + return api.requestPublisher(.getAudioRankings) + } +} diff --git a/docs/20260706_메인_콘텐츠_내부_랭킹_탭/plan-task.md b/docs/20260706_메인_콘텐츠_내부_랭킹_탭/plan-task.md new file mode 100644 index 00000000..21081bca --- /dev/null +++ b/docs/20260706_메인_콘텐츠_내부_랭킹_탭/plan-task.md @@ -0,0 +1,385 @@ +# 메인 콘텐츠 탭 내부 랭킹 탭 구현 계획 + +## 기준 문서 + +- PRD: `docs/20260706_메인_콘텐츠_내부_랭킹_탭/prd.md` +- 사용자 요청: 2026-07-06 메인 콘텐츠 탭 내부 랭킹 탭 문서 작성 요청 +- 문서 정책: `docs/agent-guides/documentation-policy.md` +- 코드 스타일: `docs/agent-guides/code-style.md` +- 빌드/검증: `docs/agent-guides/build-test-verification.md` +- 참고 구현 문서: `docs/20260630_메인_홈_랭킹_탭/plan-task.md` +- 참고 구현 문서: `docs/20260705_메인_콘텐츠_내부_추천_탭/plan-task.md` +- 참고 구현 문서: `docs/20260706_메인_콘텐츠_내부_전체_탭/plan-task.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=24-6857&m=dev` +- 1위: `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-6861&m=dev` +- 2~10위: `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-6867&m=dev` +- 11~20위: `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-6872&m=dev` + +## 요구사항 요약 + +- 메인 하단 `콘텐츠` 탭 안의 내부 `랭킹` 탭을 구현한다. +- API endpoint는 `GET /api/v2/audio/rankings`를 사용한다. +- response는 `AudioRankingResponse`와 `AudioRankingItemResponse`를 Swift `Decodable` 모델로 대응한다. +- `AudioRankingType`은 서버 enum 값 `WEEKLY_POPULAR`, `RISING`, `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`를 그대로 사용한다. +- 서버가 내려준 `items[].rank`를 화면 순위로 사용하고 클라이언트에서 index 기반 순위를 재계산하지 않는다. +- item은 최대 20개로 취급한다. +- 1위 UI는 Figma 기준 별도 형태로 구현한다. +- 2~10위 UI는 썸네일 카드 형태로 표시하고, 2~7위는 2열, 8~10위는 3열로 구성한다. +- 11~20위 UI는 row 형태로 표시하고, 텍스트 영역은 위에 콘텐츠 타이틀, 아래에 크리에이터 닉네임을 표시한다. +- `showRankChange == false`이면 순위 변화 표시는 숨긴다. +- `isNew == true`이면 신규 진입 표시를 우선한다. +- 루트 layout과 반복 item은 고정 숫자 width/height로 제한하지 않는다. +- 외부 라이브러리는 추가하지 않는다. + +## 접근안 검토 + +- 선택안: `SodaLive/Sources/V2/Main/Content/Ranking/**` 하위에 API, 모델, ViewModel, 화면, 랭킹 전용 컴포넌트를 둔다. + - 장점: 기존 콘텐츠 `추천/전체` 탭 구조와 동일하게 내부 탭별 책임을 분리할 수 있다. + - 장점: 메인 홈 랭킹 탭의 크리에이터 전용 모델과 결합하지 않고 오디오 랭킹 응답 필드를 그대로 표현할 수 있다. + - 단점: 홈 랭킹 row/card와 유사한 UI 코드가 일부 생길 수 있다. +- 대안 1: `SodaLive/Sources/V2/Main/Home/Ranking/Components/**` 컴포넌트를 직접 재사용한다. + - 장점: 순위 숫자와 rank change badge 스타일을 빠르게 맞출 수 있다. + - 단점: 기존 컴포넌트가 `MainHomeCreatorRankingItem`, `creatorId`, `nickname`, `profileImageUrl`에 묶여 있어 오디오 타이틀/크리에이터 닉네임 표시 요구와 맞지 않는다. +- 대안 2: 홈 랭킹 컴포넌트를 공용 `V2/Component/Ranking/**`으로 먼저 추출한다. + - 장점: 순위 변화 badge와 row 기초 레이아웃을 장기적으로 공유할 수 있다. + - 단점: 기존 홈 랭킹 구현까지 수정하므로 이번 랭킹 탭 범위보다 변경 범위가 커진다. + +선택안은 신규 오디오 랭킹 탭 전용 구현을 기본으로 한다. 단, 11~20위 전용 row는 새 파일로 만들더라도 `MainHomeRankingRow`의 레이아웃 구조를 거의 그대로 복사하고, 모델/탭 callback/텍스트 영역만 오디오 랭킹에 맞게 치환하는 방식을 우선한다. 2~10위는 별도 썸네일 카드 컴포넌트를 하나 만들고 2열/3열 크기 차이만 조정한다. 순위 변화 badge는 UI/조건이 동일하면 작은 공용 컴포넌트로 추출할 수 있는지 구현 직전에 다시 판단한다. + +## API 응답 모델 기준 + +```kotlin +data class AudioRankingResponse( + val showRankChange: Boolean, + val type: AudioRankingType, + val items: List +) + +enum class AudioRankingType { + WEEKLY_POPULAR, + RISING, + REVENUE, + SALES_COUNT, + COMMENT_COUNT, + LIKE_COUNT +} + +data class AudioRankingItemResponse( + val contentId: Long, + val title: String, + val creatorNickname: String, + val rank: Int, + val rankChange: Int?, + @JsonProperty("isNew") + val isNew: Boolean, + val coverImageUrl: String? +) +``` + +Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다. `Long` 대응 id는 프로젝트 기존 모델 관례에 맞춰 Swift `Int`를 우선 사용하고, `AudioRankingItemResponse` 대응 모델은 `Identifiable`을 채택해 `id == contentId`로 둔다. + +## 구현 원칙 + +- 신규 코드는 `SodaLive/Sources/V2/Main/Content/Ranking/**` 아래에 둔다. +- `MainContentView`의 `.ranking` placeholder만 신규 `MainContentRankingView`로 교체한다. +- `MainContentView`는 기존 title bar와 내부 `추천/랭킹/전체` tab bar 고정 구조를 유지한다. +- 랭킹 탭 본문만 자체 `ScrollView`로 구성한다. +- API 실패, 디코딩 실패, 빈 응답은 랭킹 탭 내부 empty/error 상태로 처리한다. +- 오디오 item 탭은 기존 `MainContentView.onTapContent` callback을 사용해 `MainView.handleRecommendationContentTap` 흐름으로 연결한다. +- `coverImageUrl`이 없거나 비어 있으면 기존 V2 이미지 fallback 관례처럼 `DownsampledKFImage`의 gray background가 보이게 한다. +- 1위는 별도 `MainContentAudioRankingTopCard`로 만들고, Figma `24:6861` 형태를 따른다. +- 2~10위는 `MainContentAudioRankingThumbnailCard` 하나를 사용하고, 2~7위는 2열, 8~10위는 3열로 parent width 기반 크기만 조정한다. +- 11~20위는 `MainContentAudioRankingRow`로 처리하고, 데이터 모델과 텍스트 영역을 `title`/`creatorNickname` 2줄 구조로 구성한다. +- 루트 container에는 고정 숫자 `frame(width:)`, `frame(height:)`를 사용하지 않는다. +- 필요한 숫자 치수는 아이콘, 내부 padding, 최소 글자 크기처럼 컴포넌트 내부 요소에만 제한적으로 사용한다. +- row/card 전체 크기는 parent width, `GeometryReader`, `aspectRatio`, `GridItem(.flexible())`, padding 기반으로 정한다. + +## 재사용 가능한 V2 위젯 후보 + +- `SodaLive/Sources/V2/Main/Content/MainContentView.swift` + - 내부 `ranking` placeholder 교체 지점. title bar와 내부 `TextTabBar` 고정 shell은 그대로 재사용한다. +- `SodaLive/Sources/V2/Component/TextTabBar.swift` + - 내부 `추천/랭킹/전체` tab bar로 이미 사용 중이다. +- `SodaLive/Sources/V2/Component/HomeTitleBar.swift` + - 콘텐츠 탭 title bar로 이미 사용 중이다. +- `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankChangeBadge.swift` + - 순위 변화 표시 조건과 asset 사용 방식의 가장 직접적인 후보. 현재 홈 랭킹 전용 경로에 있으므로 직접 재사용보다 공용 추출 또는 콘텐츠 랭킹 전용 badge 작성 중 선택한다. +- `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingRow.swift` + - 11~20위 row 크리에이터 순위 layout의 핵심 복사 후보. 모델과 텍스트 구조가 달라 직접 재사용은 어렵지만, 오디오 콘텐츠 순위 전용 row를 만들 때 순위 컬럼, 이미지, padding, `aspectRatio`, 터치 영역 구조는 거의 그대로 가져오는 것을 우선한다. +- `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingCard.swift` + - 순위 숫자와 badge overlay 처리 참고 후보. 1위는 Figma가 완전히 다르므로 구조 참고만 한다. +- `SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift` + - 오디오 썸네일 이미지, 제목, 크리에이터 닉네임 표시 후보. 기존 카드가 명시적 `width`를 받으므로 이번 랭킹 row/top card에 직접 사용하기보다 adapter와 태그 표시 관례를 참고한다. +- `SodaLive/Sources/V2/Component/AudioContent/AudioContentListRow.swift` + - 오디오 title/subtitle 2줄 텍스트 구성 참고 후보. 현재 purchase/compact 스타일과 고정 썸네일 크기가 있어 랭킹 row에는 직접 재사용하지 않는다. +- `SodaLive/Sources/V2/Component/SectionTitle.swift` + - 랭킹 타입별 섹션 제목이 필요해질 경우 후보. 현재 제공 API 응답에는 단일 `type`만 있으므로 필수는 아니다. +- `SodaLive/Sources/V2/Main/Content/All/Models/MainContentAllTabResponse.swift` + - 콘텐츠 오디오 모델의 `contentId` 대응 방식과 `creatorNickname` 필드명 참고 후보. +- `SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift` + - 콘텐츠 탭 V2 API 인증 헤더와 Moya `TargetType` 패턴 참고 후보. +- `SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllApi.swift` + - 콘텐츠 탭 V2 API endpoint 구성과 query 없는 GET/있는 GET 비교 참고 후보. + +## 주요 대상 파일 + +### 생성 + +- `SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingView.swift` +- `SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingViewModel.swift` +- `SodaLive/Sources/V2/Main/Content/Ranking/Models/AudioRankingResponse.swift` +- `SodaLive/Sources/V2/Main/Content/Ranking/Repository/MainContentRankingApi.swift` +- `SodaLive/Sources/V2/Main/Content/Ranking/Repository/MainContentRankingRepository.swift` +- `SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingTopCard.swift` +- `SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingRow.swift` +- `SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingEmptyStateView.swift` +- 필요 시 `SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentRankChangeBadge.swift` + +### 수정 + +- `SodaLive/Sources/V2/Main/Content/MainContentView.swift` +- `SodaLive/Sources/I18n/I18n.swift` +- 필요 시 `SodaLive.xcodeproj/project.pbxproj` + +### 확인 + +- `SodaLive/Sources/V2/Main/Content/MainContentTab.swift` +- `SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift` +- `SodaLive/Sources/V2/Main/Content/All/Repository/MainContentAllApi.swift` +- `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankChangeBadge.swift` +- `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingRow.swift` +- `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingCard.swift` +- `SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift` +- `SodaLive/Sources/V2/Component/AudioContent/AudioContentListRow.swift` +- `SodaLive/Sources/V2/Main/MainView.swift` +- `SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_decrease.imageset` +- `SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_increase.imageset` +- `SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_stay.imageset` +- `SodaLive/Resources/Assets.xcassets/v2/ic_rank_new.imageset` + +## TASK 체크리스트 + +### Phase 1: 기준점 점검과 재사용 후보 확정 + +- [x] **Task 1.1: 콘텐츠 랭킹 placeholder 확인** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/Main/Content/MainContentView.swift` + - 확인: `SodaLive/Sources/V2/Main/Content/MainContentTab.swift` + - 작업 내용: + - `MainContentView`의 `.ranking` 분기가 `MainPlaceholderTabView`인지 확인한다. + - `MainContentTab.ranking.title`이 `I18n.MainContentRanking.tabTitle`을 사용하는지 확인한다. + - 검증 기준: + - 실행 명령: `rg "case \\.ranking|MainPlaceholderTabView\\(title: MainContentTab.ranking.title\\)|MainContentRanking.tabTitle" SodaLive/Sources/V2/Main/Content` + - 기대 결과: 랭킹 탭은 아직 placeholder이고 내부 tab title은 이미 I18n으로 연결되어 있다. + +- [x] **Task 1.2: 순위 변화 asset과 홈 랭킹 복사 후보 컴포넌트 확인** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankChangeBadge.swift` + - 확인: `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingRow.swift` + - 확인: `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingCard.swift` + - 확인: `SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_decrease.imageset` + - 확인: `SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_increase.imageset` + - 확인: `SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_stay.imageset` + - 확인: `SodaLive/Resources/Assets.xcassets/v2/ic_rank_new.imageset` + - 작업 내용: + - 홈 랭킹의 badge 조건과 asset 이름을 확인한다. + - 11~20위 row가 거의 그대로 복사할 수 있는 `MainHomeRankingRow`의 순위 컬럼, 이미지, padding, `aspectRatio`, 터치 영역 구조를 확인한다. + - 검증 기준: + - 실행 명령: `find SodaLive/Resources/Assets.xcassets/v2 -maxdepth 2 -name 'ic_rank_*' | sort && rg "MainHomeRankChangeBadge|MainHomeRankingRow|aspectRatio|ic_rank_new|ic_rank_caret" SodaLive/Sources/V2/Main/Home/Ranking/Components` + - 기대 결과: 순위 변화 asset과 홈 랭킹 row의 구조 복사 가능 지점이 확인된다. + +### Phase 2: API와 응답 모델 + +- [x] **Task 2.1: 오디오 랭킹 응답 모델 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Content/Ranking/Models/AudioRankingResponse.swift` + - 작업 내용: + - `AudioRankingResponse`, `AudioRankingType`, `AudioRankingItemResponse`를 Swift 모델로 만든다. + - `AudioRankingType`은 `String`, `Decodable`, `CaseIterable`, `Hashable`을 채택한다. + - `AudioRankingItemResponse`는 `Identifiable`을 채택하고 `id`는 `contentId`를 사용한다. + - Kotlin `Long`은 기존 V2 관례에 맞춰 Swift `Int`로 둔다. + - 검증 기준: + - 실행 명령: `rg "struct AudioRankingResponse|enum AudioRankingType|WEEKLY_POPULAR|RISING|REVENUE|SALES_COUNT|COMMENT_COUNT|LIKE_COUNT|struct AudioRankingItemResponse|contentId|creatorNickname|coverImageUrl|var id" SodaLive/Sources/V2/Main/Content/Ranking/Models/AudioRankingResponse.swift` + - 기대 결과: 사용자 제공 response data class와 enum 값이 모두 Swift 모델에 반영된다. + +- [x] **Task 2.2: 오디오 랭킹 API endpoint와 repository 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Content/Ranking/Repository/MainContentRankingApi.swift` + - 생성: `SodaLive/Sources/V2/Main/Content/Ranking/Repository/MainContentRankingRepository.swift` + - 확인: `SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift` + - 작업 내용: + - `MainContentRankingApi`를 `TargetType`으로 만든다. + - case는 `getAudioRankings` 하나만 둔다. + - path는 `/api/v2/audio/rankings`, method는 `.get`, task는 `.requestPlain`로 둔다. + - headers는 기존 콘텐츠 V2 API와 동일하게 `Authorization: Bearer {token}` 패턴을 사용한다. + - repository는 `getAudioRankings() -> AnyPublisher`를 제공한다. + - 검증 기준: + - 실행 명령: `rg "/api/v2/audio/rankings|getAudioRankings|Authorization|requestPlain|AnyPublisher" SodaLive/Sources/V2/Main/Content/Ranking/Repository` + - 기대 결과: 신규 endpoint와 repository 메서드가 콘텐츠 랭킹 전용 경로에만 존재한다. + +### Phase 3: ViewModel 상태와 데이터 로딩 + +- [x] **Task 3.1: 랭킹 ViewModel 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingViewModel.swift` + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - `MainContentRankingViewModel`을 `ObservableObject`로 만든다. + - `isLoading`, `isShowPopup`, `errorMessage`, `ranking`, `hasLoaded` 상태를 둔다. + - `fetchRankings()`에서 `ApiResponse`를 디코딩한다. + - 성공 시 `ranking.items`는 서버 순서를 유지하되 최대 20개만 표시 대상으로 보관한다. + - API 실패/디코딩 실패 시 `I18n.MainContentRanking.loadFailedMessage`를 표시한다. + - 빈 배열이면 `I18n.MainContentRanking.emptyStateMessage`를 표시한다. + - 검증 기준: + - 실행 명령: `rg "final class MainContentRankingViewModel|fetchRankings|ApiResponse|prefix\\(20\\)|emptyStateMessage|loadFailedMessage|hasLoaded" SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingViewModel.swift SodaLive/Sources/I18n/I18n.swift` + - 기대 결과: 최대 20개 처리, 로딩/실패/empty 상태, I18n 문구가 확인된다. + +### Phase 4: 순위 변화 표시 컴포넌트 + +- [x] **Task 4.1: rank change badge 구현 방식 확정** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankChangeBadge.swift` + - 생성 또는 수정: `SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentRankChangeBadge.swift` + - 작업 내용: + - 홈 랭킹 badge를 공용으로 옮기는 변경이 과도하면 콘텐츠 랭킹 전용 `MainContentRankChangeBadge`를 만든다. + - 입력값은 `showRankChange: Bool`, `rankChange: Int?`, `isNew: Bool`로 둔다. + - `showRankChange == false`이면 아무것도 표시하지 않는다. + - `isNew == true`이면 `Image("ic_rank_new")`를 우선 표시한다. + - `rankChange > 0`이면 절댓값 숫자와 `Image("ic_rank_caret_increase")`를 표시한다. + - `rankChange < 0`이면 절댓값 숫자와 `Image("ic_rank_caret_decrease")`를 표시한다. + - `rankChange == 0`이면 숫자 없이 `Image("ic_rank_caret_stay")`를 표시한다. + - `rankChange == nil && isNew == false`이면 아무것도 표시하지 않는다. + - 검증 기준: + - 실행 명령: `rg "struct MainContentRankChangeBadge|ic_rank_new|ic_rank_caret_increase|ic_rank_caret_decrease|ic_rank_caret_stay|showRankChange|rankChange|isNew" SodaLive/Sources/V2/Main/Content/Ranking/Components` + - 기대 결과: 콘텐츠 랭킹에서 사용할 순위 변화 표시 조건과 asset 이름이 확인된다. + +### Phase 5: 1위, 2~10위 썸네일 카드, 11~20위 row UI 컴포넌트 + +- [x] **Task 5.1: 1위 전용 카드 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingTopCard.swift` + - 작업 내용: + - Figma `24:6861` 기준 1위 전용 UI를 구성한다. + - `coverImageUrl`, `rank`, `rankChange`, `isNew`, `title`, `creatorNickname`을 표시한다. + - tap 시 `onTapContent(item.contentId)`를 호출한다. + - 루트 container에 고정 숫자 `frame(width:)`, `frame(height:)`를 사용하지 않는다. + - 전체 크기는 parent width와 `aspectRatio` 또는 container-relative 제약으로 잡는다. + - Preview에는 rank change 상승, 신규 진입, 순위 변화 숨김 상태를 포함한다. + - 검증 기준: + - 실행 명령: `rg "struct MainContentAudioRankingTopCard|DownsampledKFImage|item.contentId|item.title|item.creatorNickname|MainContentRankChangeBadge|onTapContent\\(item.contentId\\)|aspectRatio|PreviewProvider|showRankChange: false|isNew: true" SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingTopCard.swift` + - 기대 결과: 1위 전용 UI가 콘텐츠 응답 필드를 사용하고, 전체 크기는 반응형 제약으로 구성된다. + +- [x] **Task 5.2: 2~10위 썸네일 카드와 11~20위 row 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingRow.swift` + - 확인: `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingRow.swift` + - 작업 내용: + - 2~10위 item은 썸네일 카드 방식으로 표시한다. + - 2~7위는 한 줄에 2개, 8~10위는 한 줄에 3개로 배치한다. + - 2~7위와 8~10위는 동일한 `MainContentAudioRankingThumbnailCard`를 사용하고, `columnsPerRow`에 따라 크기와 내부 요소 크기만 조정한다. + - 11~20위 item은 row 방식으로 표시한다. + - 11~20위 row는 메인 홈 랭킹 탭의 크리에이터 순위 row 크기와 반응형 layout을 거의 그대로 복사한다. + - 단, 모델 입력은 `AudioRankingItemResponse` 대응 타입으로 바꾸고 tap callback은 `onTapContent(item.contentId)`로 바꾼다. + - 텍스트 영역은 위에 `item.title`, 아래에 `item.creatorNickname`을 표시한다. + - rank 숫자는 `item.rank`를 사용한다. + - 순위 변화 표시는 `MainContentRankChangeBadge`를 사용한다. + - tap 시 `onTapContent(item.contentId)`를 호출한다. + - 루트 container에 고정 숫자 `frame(width:)`, `frame(height:)`를 사용하지 않는다. + - card/row 전체 높이는 고정 숫자로 박지 않고 내부 padding, image aspect ratio, parent width 기반 제약으로 구성한다. + - Preview에는 2위, 10위, 11위, 20위와 상승, 하락, 변동 없음, 신규 진입, 순위 변화 숨김 상태를 배치한다. + - 검증 기준: + - 실행 명령: `rg "struct MainContentAudioRankingRow|DownsampledKFImage|item.rank|item.title|item.creatorNickname|MainContentRankChangeBadge|onTapContent\\(item.contentId\\)|aspectRatio|PreviewProvider|rank: 2|rank: 20|showRankChange: false|isNew: true" SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingRow.swift` + - 기대 결과: 2~10위는 동일 썸네일 카드 컴포넌트를 2열/3열 크기로 사용하고, 11~20위 row는 홈 랭킹 row 구조를 거의 유지하면서 오디오 title/creatorNickname 2줄 텍스트와 content tap callback만 바꾼다. + +- [x] **Task 5.3: empty state 컴포넌트 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingEmptyStateView.swift` + - 수정: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - `I18n.MainContentRanking.emptyStateMessage`를 랭킹 콘텐츠 영역 안에 표시한다. + - 배경과 텍스트 스타일은 기존 V2 콘텐츠 추천/전체 empty state와 맞춘다. + - 검증 기준: + - 실행 명령: `rg "struct MainContentAudioRankingEmptyStateView|I18n.MainContentRanking.emptyStateMessage|Color.black" SodaLive/Sources/V2/Main/Content/Ranking/Components/MainContentAudioRankingEmptyStateView.swift SodaLive/Sources/I18n/I18n.swift` + - 기대 결과: empty/API 실패 공통 표시 컴포넌트와 I18n 문구가 확인된다. + +### Phase 6: 랭킹 탭 화면 조립과 상세 진입 연결 + +- [x] **Task 6.1: MainContentRankingView 구현** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingView.swift` + - 작업 내용: + - `@StateObject private var viewModel = MainContentRankingViewModel()`을 사용한다. + - 최초 `onAppear`에서 `hasLoaded == false`일 때만 `fetchRankings()`를 호출한다. + - loading, empty, content 상태를 분기한다. + - content 상태에서는 1위가 있으면 `MainContentAudioRankingTopCard`로 표시한다. + - 2~7위는 `MainContentAudioRankingThumbnailCard`를 2열 grid로 표시한다. + - 8~10위는 같은 `MainContentAudioRankingThumbnailCard`를 3열 grid로 표시한다. + - 11~20위는 `MainContentAudioRankingRow`로 표시한다. + - `showRankChange`와 `onTapContent`를 top card/thumbnail card/row에 전달한다. + - 내부 tab bar와 title bar는 만들지 않고 `MainContentView` shell에 맡긴다. + - 검증 기준: + - 실행 명령: `rg "struct MainContentRankingView|MainContentRankingViewModel|fetchRankings|MainContentAudioRankingTopCard|MainContentAudioRankingRow|MainContentAudioRankingEmptyStateView|onTapContent|MainPlaceholderTabView|TextTabBar|HomeTitleBar" SodaLive/Sources/V2/Main/Content/Ranking/MainContentRankingView.swift` + - 기대 결과: `MainPlaceholderTabView`, `TextTabBar`, `HomeTitleBar`는 검색되지 않고, 랭킹 ViewModel과 top card/row 조립이 확인된다. + +- [x] **Task 6.2: MainContentView에서 랭킹 탭 연결** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Content/MainContentView.swift` + - 확인: `SodaLive/Sources/V2/Main/MainView.swift` + - 작업 내용: + - `.ranking` 분기의 `MainPlaceholderTabView`를 `MainContentRankingView(onTapContent: onTapContent)`로 교체한다. + - 기존 `onTapContent` callback을 그대로 재사용해 오디오 상세 진입 guard 흐름을 유지한다. + - 검증 기준: + - 실행 명령: `rg "MainContentRankingView\\(onTapContent: onTapContent\\)|MainPlaceholderTabView\\(title: MainContentTab.ranking.title\\)|handleRecommendationContentTap" SodaLive/Sources/V2/Main/Content/MainContentView.swift SodaLive/Sources/V2/Main/MainView.swift` + - 기대 결과: 랭킹 placeholder는 검색되지 않고, 랭킹 item tap은 기존 콘텐츠 상세 callback으로 연결된다. + +### Phase 7: 프로젝트 연결과 전체 검증 + +- [x] **Task 7.1: Xcode 프로젝트 포함 여부 확인** + - 대상 파일: + - 확인/수정: `SodaLive.xcodeproj/project.pbxproj` + - 작업 내용: + - 신규 Swift 파일이 Xcode project에 자동 포함되지 않는 구조이면 project file에 포함한다. + - XcodeGen/SPM 자동 discovery 구조가 아니면 누락 파일 없이 target membership을 맞춘다. + - 검증 기준: + - 실행 명령: `rg "MainContentRankingView|AudioRankingResponse|MainContentRankingApi|MainContentAudioRankingRow" SodaLive.xcodeproj/project.pbxproj` + - 기대 결과: 프로젝트 파일 관리 방식에 따라 신규 Swift 파일 참조가 확인되거나, 자동 포함 구조라면 별도 수정이 필요 없다는 근거가 확인된다. + +- [x] **Task 7.2: 정적 검색 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/Main/Content/Ranking/**` + - 확인: `SodaLive/Sources/V2/Main/Content/MainContentView.swift` + - 확인: `SodaLive/Sources/I18n/I18n.swift` + - 작업 내용: + - endpoint, model, ViewModel, UI 연결, I18n 문구를 정적 검색으로 확인한다. + - 전체 루트 UI에서 고정 width/height를 강제한 지점을 검색한다. + - 검증 기준: + - 실행 명령: `rg "/api/v2/audio/rankings|AudioRankingResponse|MainContentRankingView|MainContentAudioRankingTopCard|MainContentAudioRankingRow|emptyStateMessage|loadFailedMessage" SodaLive/Sources/V2/Main/Content SodaLive/Sources/I18n/I18n.swift` + - 기대 결과: API, 모델, 화면 연결, I18n 문구가 확인된다. + - 실행 명령: `rg "frame\\(width:|frame\\(height:" SodaLive/Sources/V2/Main/Content/Ranking` + - 기대 결과: 루트 container의 고정 width/height 강제가 없고, 아이콘/이미지/내부 요소에 필요한 제한만 존재한다. + +- [x] **Task 7.3: 빌드 검증** + - 대상 파일: + - 확인: `docs/agent-guides/build-test-verification.md` + - 작업 내용: + - 프로젝트에서 사용하는 최신 빌드 검증 명령을 문서에서 확인한 뒤 실행한다. + - 검증 기준: + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -sdk iphonesimulator build` + - 기대 결과: `** BUILD SUCCEEDED **` + +## 검증 기록 + +- 2026-07-06: 사용자 요청에 따라 구현 대상 Figma URL 4개를 본 문서에 기록했다. +- 2026-07-06: 누락된 PRD를 `docs/20260706_메인_콘텐츠_내부_랭킹_탭/prd.md`로 추가하고 본 문서의 기준 문서에 연결했다. +- 2026-07-06: 사용자 피드백을 반영해 2~10위는 썸네일 카드, 11~20위는 row 형태로 구현하도록 문서를 보정했다. +- 2026-07-06: `SodaLive/Sources/V2/Main/Content/MainContentView.swift`에서 내부 `ranking` 탭이 아직 `MainPlaceholderTabView`임을 확인했다. +- 2026-07-06: V2 재사용 후보로 `TextTabBar`, `HomeTitleBar`, 홈 랭킹 badge/row/card, `AudioContentThumbnailCard`, `AudioContentListRow`, 콘텐츠 API 패턴 파일을 확인해 본 문서에 기록했다. +- 2026-07-06: 사용자 요청 범위가 “문서만 작성”이므로 Swift 소스, Xcode 프로젝트, asset은 수정하지 않았다. +- 2026-07-06: `SodaLive/Sources/V2/Main/Content/Ranking/**` 하위에 오디오 랭킹 모델, API, Repository, ViewModel, 전용 badge/top card/row/empty state/view를 추가했다. +- 2026-07-06: `MainContentView`의 `.ranking` placeholder를 `MainContentRankingView(onTapContent: onTapContent)`로 교체해 기존 콘텐츠 상세 진입 callback을 재사용하도록 연결했다. +- 2026-07-06: `SodaLive.xcodeproj/project.pbxproj`에 신규 랭킹 Swift 파일 9개를 3개 앱 타깃 Sources에 포함했다. +- 2026-07-06: 테스트 번들 타깃이 확인되지 않아 신규 테스트 인프라는 추가하지 않고, 정적 검색 검증과 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -sdk iphonesimulator build`로 검증했다. 최초 빌드에서 `SodaSpacing.s2` 미존재 오류를 확인해 `SodaSpacing.s4`로 수정했고, 재실행 결과 `** BUILD SUCCEEDED **`를 확인했다. +- 2026-07-06: Figma `24:6867`, `24:6872`를 재확인해 2~10위는 썸네일 카드, 11~20위는 row 형태임을 반영했다. `MainContentAudioRankingThumbnailCard`를 추가하고 `MainContentRankingView`에서 2~7위는 2열, 8~10위는 3열, 11~20위는 row로 분기하도록 수정했다. diff --git a/docs/20260706_메인_콘텐츠_내부_랭킹_탭/prd.md b/docs/20260706_메인_콘텐츠_내부_랭킹_탭/prd.md new file mode 100644 index 00000000..4bce2458 --- /dev/null +++ b/docs/20260706_메인_콘텐츠_내부_랭킹_탭/prd.md @@ -0,0 +1,173 @@ +# PRD: 메인 콘텐츠 탭 내부 랭킹 탭 + +## 1. Overview +메인 하단 `콘텐츠` 탭 안의 내부 `랭킹` 탭에서 `GET /api/v2/audio/rankings` 응답을 사용해 오디오 콘텐츠 순위를 제공한다. + +상단 title bar와 내부 `추천/랭킹/전체` tab bar는 기존 콘텐츠 탭 구조를 유지한다. 내부 `랭킹` 탭 화면에서는 서버가 내려준 순위와 순위 변동 정보를 기준으로 1위 전용 UI, 2~20위 row UI를 구성한다. 신규 기능이므로 API, 모델, ViewModel, 화면, 랭킹 전용 컴포넌트는 `SodaLive/Sources/V2/Main/Content/Ranking/**` 하위에 둔다. + +Figma 참조: +- 전체: `24:6857`, `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-6857&m=dev` +- 1위: `24:6861`, `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-6861&m=dev` +- 2~10위: `24:6867`, `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-6867&m=dev` +- 11~20위: `24:6872`, `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-6872&m=dev` + +## 2. Problem +- 현재 V2 메인 콘텐츠 탭의 내부 `랭킹` 탭은 placeholder 상태라 사용자가 콘텐츠 랭킹을 볼 수 없다. +- 기존 메인 홈 `랭킹` 탭은 크리에이터 순위용 응답과 UI에 맞춰져 있어 오디오 콘텐츠 랭킹의 `title`, `creatorNickname`, `coverImageUrl`, `contentId` 요구사항을 그대로 수용하기 어렵다. +- Figma에서 1위 UI는 2~20위와 형태가 다르므로 순위별 컴포넌트 경계를 명확히 나눠야 한다. +- 2~20위는 표시 데이터만 다르고 UI 형태가 메인 홈 랭킹 탭의 크리에이터 순위 row와 매우 유사하므로, 전용 컴포넌트를 만들더라도 `MainHomeRankingRow` 구조를 거의 그대로 복사해 사용할 수 있는지 먼저 검토한다. +- 2~20위 row에서 달라지는 핵심 영역은 텍스트 영역이며, 위에는 콘텐츠 타이틀, 아래에는 크리에이터 닉네임을 표시한다. +- 고정 width/height를 직접 박으면 다양한 기기 폭에서 Figma 구조가 깨질 수 있으므로 부모 width, aspect ratio, padding 기반의 반응형 제약이 필요하다. + +## 3. Goals +- 메인 콘텐츠 탭 내부 `랭킹` 탭에서 `GET /api/v2/audio/rankings` 응답 기반 UI를 표시한다. +- `AudioRankingResponse`, `AudioRankingType`, `AudioRankingItemResponse`를 Swift `Decodable` 모델로 반영한다. +- 서버가 내려준 `items[].rank`를 화면 순위로 사용하고 클라이언트에서 index 기반 순위를 재계산하지 않는다. +- item은 최대 20개까지 표시한다. +- 1위는 Figma `24:6861` 기준 전용 UI로 구현한다. +- 2~20위는 단일 row 컴포넌트로 구현하고, 메인 홈 랭킹 탭의 크리에이터 순위 row 구조를 거의 그대로 복사한 뒤 오디오 콘텐츠 필드에 맞게 치환하는 방식을 우선한다. +- 2~20위 row의 텍스트 영역은 위에 콘텐츠 타이틀, 아래에 크리에이터 닉네임을 표시한다. +- `showRankChange`, `rankChange`, `isNew` 값에 따라 순위 변화 UI를 표시하거나 숨긴다. +- 오디오 item 탭 시 기존 콘텐츠 상세 진입 callback을 재사용한다. +- V2 패키지 하위에 구현된 재사용 가능한 위젯 후보를 확인하고, 직접 재사용이 어렵더라도 구조 복사에 가까운 재활용이 가능한 부분을 명시한다. + +## 4. Non-Goals +- 실제 구현과 Xcode 프로젝트 수정은 이 PRD 작성 범위에 포함하지 않는다. +- API 스펙에 없는 랭킹 타입 filter UI를 임의로 추가하지 않는다. +- 서버 응답의 `rank`를 무시하고 클라이언트 index로 순위를 재계산하지 않는다. +- 20위를 초과한 item 노출 정책을 새로 만들지 않는다. +- 기존 메인 홈 `랭킹` 탭 동작을 변경하지 않는다. +- 기존 legacy `SodaLive/Sources/Content/**` 화면을 리팩터링하지 않는다. +- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다. +- 외부 라이브러리를 추가하지 않는다. +- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. + +## 5. Target Users +- 메인 콘텐츠 탭에서 인기 오디오 콘텐츠 순위를 빠르게 확인하려는 사용자 +- 순위 변화, 신규 진입 여부를 보고 콘텐츠를 선택하려는 사용자 +- 콘텐츠 타이틀과 크리에이터 닉네임을 함께 확인한 뒤 오디오 상세로 이동하려는 사용자 + +## 6. User Stories +- 사용자는 콘텐츠 탭의 내부 `랭킹` 탭을 누르면 현재 오디오 랭킹을 보고 싶다. +- 사용자는 1위 콘텐츠를 다른 순위보다 더 큰 시각적 강조로 확인하고 싶다. +- 사용자는 2~20위 콘텐츠를 같은 row 형식으로 빠르게 훑어보고 싶다. +- 사용자는 각 row에서 콘텐츠 타이틀과 크리에이터 닉네임을 구분해 보고 싶다. +- 사용자는 순위 상승, 하락, 유지, 신규 진입 상태를 확인하고 싶다. +- 사용자는 랭킹 item을 눌러 기존 오디오 상세 화면으로 이동하고 싶다. + +## 7. Core Requirements + +### 7.1 API +- Method: `GET` +- Path: `/api/v2/audio/rankings` +- 인증: 기존 V2 API 인증 헤더 패턴을 따른다. +- Request query: 현재 제공 명세 기준 없음. +- 응답 래퍼: 기존 관례대로 `ApiResponse` 디코딩을 우선한다. + +### 7.2 Response model +```kotlin +data class AudioRankingResponse( + val showRankChange: Boolean, + val type: AudioRankingType, + val items: List +) + +enum class AudioRankingType { + WEEKLY_POPULAR, + RISING, + REVENUE, + SALES_COUNT, + COMMENT_COUNT, + LIKE_COUNT +} + +data class AudioRankingItemResponse( + val contentId: Long, + val title: String, + val creatorNickname: String, + val rank: Int, + val rankChange: Int?, + @JsonProperty("isNew") + val isNew: Boolean, + val coverImageUrl: String? +) +``` + +Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체와 `String` raw value enum으로 선언한다. Kotlin `Long` 대응 값은 프로젝트 기존 모델 관례에 맞춰 Swift `Int`를 우선 사용한다. `AudioRankingItemResponse` 대응 모델은 `Identifiable`을 채택하고 `id == contentId`로 둔다. + +### 7.3 Ranking display +- 서버 응답 `items` 순서를 유지한다. +- 화면 순위 숫자는 `item.rank`를 사용한다. +- 클라이언트에서 index 기반으로 순위를 다시 계산하지 않는다. +- 표시 대상은 최대 20개로 제한한다. +- item이 없으면 empty state를 표시한다. + +### 7.4 Rank change display +- `showRankChange == false`이면 순위 변화 영역을 표시하지 않는다. +- `showRankChange == true`이고 `isNew == true`이면 신규 진입 표시를 우선한다. +- `rankChange > 0`이면 상승 표시와 절댓값 숫자를 표시한다. +- `rankChange < 0`이면 하락 표시와 절댓값 숫자를 표시한다. +- `rankChange == 0`이면 변동 없음 표시를 사용한다. +- `rankChange == nil && isNew == false`이면 순위 변화 영역을 표시하지 않는다. +- 기존 asset 후보는 `ic_rank_new`, `ic_rank_caret_increase`, `ic_rank_caret_decrease`, `ic_rank_caret_stay`를 우선 확인한다. + +### 7.5 UI layout +- 내부 tab bar에서 `랭킹`이 선택된 상태로 표시된다. +- title bar와 내부 tab bar는 기존 `MainContentView` shell을 유지한다. +- 랭킹 탭 본문은 자체 `ScrollView`로 구성한다. +- 1위 item은 Figma `24:6861` 기준 전용 card로 표시한다. +- 2~20위 item은 Figma `24:6867`, `24:6872` 기준 row로 표시한다. +- 2~20위 row는 `MainHomeRankingRow`의 순위 컬럼, 썸네일, padding, `aspectRatio`, 터치 영역 구조를 최대한 유지하고, 데이터 모델과 텍스트 영역만 오디오 랭킹에 맞게 바꾼다. +- 2~20위 row의 텍스트 영역은 위 `title`, 아래 `creatorNickname` 순서로 표시한다. +- 텍스트가 길면 Figma 기준 line limit 안에서 말줄임 처리한다. +- 루트 layout과 반복 item에는 고정 숫자 width/height를 사용하지 않는다. +- 필요한 숫자 치수는 아이콘, 내부 padding, 최소 글자 크기 같은 내부 요소에만 제한적으로 사용한다. +- card/row 전체 크기는 parent width, `GeometryReader`, `aspectRatio`, flexible layout, padding 기반으로 정한다. + +### 7.6 Navigation +- 랭킹 item 탭은 `contentId`로 기존 content detail 진입 흐름을 사용한다. +- `MainContentView`는 기존 `onTapContent` callback을 `MainContentRankingView`에 전달한다. +- 기존 `MainView.handleRecommendationContentTap`의 로그인/성인 콘텐츠 guard 흐름을 재사용한다. + +### 7.7 Empty and error state +- 첫 로드 중에는 loading 상태를 표시한다. +- 첫 페이지 응답이 비어 있으면 empty state를 표시한다. +- API 실패 또는 디코딩 실패 시 사용자 노출 문구를 `I18n.MainContentRanking`에 둔다. +- 기존 목록이 없는 실패 상태에서는 랭킹 탭 내부 안내를 표시한다. + +## 8. UX / UI Expectations +- 전체 배경은 기존 V2 dark UI 기준을 따른다. +- 1위는 Figma 기준으로 다른 순위보다 시각적 우선순위가 높게 보이도록 구성한다. +- 2~20위는 반복 row로 스캔하기 쉬워야 한다. +- 순위 숫자와 순위 변화 표시는 홈 랭킹 탭과 시각적 일관성을 유지한다. +- 썸네일은 `coverImageUrl`을 사용하고, 값이 없거나 비어 있으면 기존 V2 이미지 fallback 배경을 따른다. +- 콘텐츠 타이틀은 크리에이터 닉네임보다 우선되는 텍스트 계층으로 표시한다. +- 카드와 row의 터치 영역은 사용자가 오디오 상세 진입 가능성을 인지할 수 있게 item 전체 영역으로 둔다. + +## 9. Technical Constraints +- 신규 기능 파일은 `SodaLive/Sources/V2/Main/Content/Ranking/**` 하위에 둔다. +- 콘텐츠 탭 shell은 기존 `SodaLive/Sources/V2/Main/Content/MainContentView.swift` 구조를 유지한다. +- 여러 화면에서 재사용할 명확한 근거가 생긴 컴포넌트만 `SodaLive/Sources/V2/Component/**`로 승격한다. +- 기존 홈 랭킹 row 컴포넌트는 모델 결합이 있으므로 직접 재사용은 어렵지만, 2~20위 오디오 row 전용 컴포넌트 작성 시 레이아웃 구조는 거의 그대로 복사하는 것을 우선 검토한다. +- 기존 `TextTabBar`, `HomeTitleBar`, 홈 랭킹 badge/row/card, `AudioContentThumbnailCard`, `AudioContentListRow`를 재사용 후보로 먼저 확인한다. +- 신규 사용자 노출 문구는 `SodaLive/Sources/I18n/I18n.swift`에 ko/en/ja로 추가한다. +- 외부 라이브러리는 추가하지 않는다. + +## 10. Success Criteria +- `docs/20260706_메인_콘텐츠_내부_랭킹_탭/plan-task.md`가 본 PRD를 기준 문서로 참조한다. +- `.content` 탭 내부 `랭킹` 분기에서 placeholder 대신 랭킹 화면을 표시하는 구현 계획이 있다. +- `GET /api/v2/audio/rankings` endpoint와 응답 모델이 문서화되어 있다. +- Figma 전체, 1위, 2~10위, 11~20위 URL이 PRD와 계획 문서에 기록되어 있다. +- 1위 전용 UI와 2~20위 row UI가 분리되어 계획되어 있다. +- 2~20위 row의 텍스트 영역이 `title`/`creatorNickname` 구조임이 명시되어 있다. +- 고정 width/height 금지와 반응형 layout 원칙이 명시되어 있다. +- V2 재사용 가능한 위젯 후보가 계획 문서에 기록되어 있다. +- 구현 단계에서는 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug -sdk iphonesimulator build`가 성공해야 한다. + +## 11. Open Questions +- 현재 제공 요구사항 기준으로 미결정 사항은 없다. + +## 12. Verification Notes +- 2026-07-06: 사용자 요청으로 최초 `plan-task.md`가 먼저 작성되었고, 이후 PRD 누락 지적에 따라 본 PRD를 추가했다. +- 2026-07-06: `docs/prd/sample-prd.md`, `docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md`, `docs/20260706_메인_콘텐츠_내부_랭킹_탭/plan-task.md`를 확인해 PRD 형식과 계획 문서 내용을 맞췄다. +- 2026-07-06: 사용자 요청 범위가 문서 생성이므로 Swift 소스, Xcode 프로젝트, asset은 수정하지 않는다.