feat(content): 랭킹 탭을 추가한다

This commit is contained in:
Yu Sung
2026-07-06 20:19:27 +09:00
parent bae92eba4b
commit d129583d3b
15 changed files with 1322 additions and 1 deletions

View File

@@ -1700,6 +1700,36 @@
C0A706000000000000000001 /* AudioContentThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A706000000000000000000 /* AudioContentThumbnailCard.swift */; }; C0A706000000000000000001 /* AudioContentThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A706000000000000000000 /* AudioContentThumbnailCard.swift */; };
C0A706000000000000000002 /* 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 */; }; 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 */; }; 7B2EE8B4132668F0267B10CB /* UserProfileChannelDonationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */; };
7BED9B1CFDC729EC4AF42B35 /* Radius.swift in Sources */ = {isa = PBXBuildFile; fileRef = E31B21FA615404F2E9E7839F /* Radius.swift */; }; 7BED9B1CFDC729EC4AF42B35 /* Radius.swift in Sources */ = {isa = PBXBuildFile; fileRef = E31B21FA615404F2E9E7839F /* Radius.swift */; };
80209D5731C339163084890C /* LanguageSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 716260E8214A329EE07CAAFA /* LanguageSettingsViewModel.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 = "<group>"; }; C0A70000000000000000000C /* MainContentMostCommentedAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentMostCommentedAudioSection.swift; sourceTree = "<group>"; };
C0A70000000000000000000D /* MainContentRecommendedAudioGridSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendedAudioGridSection.swift; sourceTree = "<group>"; }; C0A70000000000000000000D /* MainContentRecommendedAudioGridSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendedAudioGridSection.swift; sourceTree = "<group>"; };
C0A70000000000000000000F /* MainContentAudioEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioEmptyStateView.swift; sourceTree = "<group>"; }; C0A70000000000000000000F /* MainContentAudioEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioEmptyStateView.swift; sourceTree = "<group>"; };
C0A720000000000000000001 /* AudioRankingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRankingResponse.swift; sourceTree = "<group>"; };
C0A720000000000000000002 /* MainContentRankingApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRankingApi.swift; sourceTree = "<group>"; };
C0A720000000000000000003 /* MainContentRankingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRankingRepository.swift; sourceTree = "<group>"; };
C0A720000000000000000004 /* MainContentRankingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRankingViewModel.swift; sourceTree = "<group>"; };
C0A720000000000000000005 /* MainContentRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRankingView.swift; sourceTree = "<group>"; };
C0A720000000000000000006 /* MainContentRankChangeBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRankChangeBadge.swift; sourceTree = "<group>"; };
C0A720000000000000000007 /* MainContentAudioRankingTopCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioRankingTopCard.swift; sourceTree = "<group>"; };
C0A720000000000000000008 /* MainContentAudioRankingRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioRankingRow.swift; sourceTree = "<group>"; };
C0A720000000000000000009 /* MainContentAudioRankingEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioRankingEmptyStateView.swift; sourceTree = "<group>"; };
C0A72000000000000000000A /* MainContentAudioRankingThumbnailCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioRankingThumbnailCard.swift; sourceTree = "<group>"; };
401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeLiveSection.swift; sourceTree = "<group>"; }; 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeLiveSection.swift; sourceTree = "<group>"; };
401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeBannerSection.swift; sourceTree = "<group>"; }; 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeBannerSection.swift; sourceTree = "<group>"; };
401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeActiveCreatorSection.swift; sourceTree = "<group>"; }; 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeActiveCreatorSection.swift; sourceTree = "<group>"; };
@@ -3220,6 +3260,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C0A700000000000000000102 /* Recommendation */, C0A700000000000000000102 /* Recommendation */,
C0A720000000000000000100 /* Ranking */,
C0A710000000000000000100 /* All */, C0A710000000000000000100 /* All */,
C0A700000000000000000001 /* MainContentView.swift */, C0A700000000000000000001 /* MainContentView.swift */,
C0A700000000000000000002 /* MainContentTab.swift */, C0A700000000000000000002 /* MainContentTab.swift */,
@@ -3270,6 +3311,47 @@
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
C0A720000000000000000100 /* Ranking */ = {
isa = PBXGroup;
children = (
C0A720000000000000000103 /* Components */,
C0A720000000000000000101 /* Models */,
C0A720000000000000000102 /* Repository */,
C0A720000000000000000004 /* MainContentRankingViewModel.swift */,
C0A720000000000000000005 /* MainContentRankingView.swift */,
);
path = Ranking;
sourceTree = "<group>";
};
C0A720000000000000000101 /* Models */ = {
isa = PBXGroup;
children = (
C0A720000000000000000001 /* AudioRankingResponse.swift */,
);
path = Models;
sourceTree = "<group>";
};
C0A720000000000000000102 /* Repository */ = {
isa = PBXGroup;
children = (
C0A720000000000000000002 /* MainContentRankingApi.swift */,
C0A720000000000000000003 /* MainContentRankingRepository.swift */,
);
path = Repository;
sourceTree = "<group>";
};
C0A720000000000000000103 /* Components */ = {
isa = PBXGroup;
children = (
C0A720000000000000000006 /* MainContentRankChangeBadge.swift */,
C0A720000000000000000007 /* MainContentAudioRankingTopCard.swift */,
C0A720000000000000000008 /* MainContentAudioRankingRow.swift */,
C0A72000000000000000000A /* MainContentAudioRankingThumbnailCard.swift */,
C0A720000000000000000009 /* MainContentAudioRankingEmptyStateView.swift */,
);
path = Components;
sourceTree = "<group>";
};
401A3F992FEEAFA000A5BBA3 /* Models */ = { 401A3F992FEEAFA000A5BBA3 /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -6416,6 +6498,16 @@
C0A70100000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */, C0A70100000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */,
C0A70100000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */, C0A70100000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */,
C0A70100000000000000000F /* MainContentAudioEmptyStateView.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 */, C0A706000000000000000003 /* AudioContentThumbnailCard.swift in Sources */,
401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
@@ -7278,6 +7370,16 @@
C0A70200000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */, C0A70200000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */,
C0A70200000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */, C0A70200000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */,
C0A70200000000000000000F /* MainContentAudioEmptyStateView.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 */, C0A706000000000000000001 /* AudioContentThumbnailCard.swift in Sources */,
401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
@@ -8180,6 +8282,16 @@
C0A70300000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */, C0A70300000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */,
C0A70300000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */, C0A70300000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */,
C0A70300000000000000000F /* MainContentAudioEmptyStateView.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 */, C0A706000000000000000002 /* AudioContentThumbnailCard.swift in Sources */,
401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,

View File

@@ -3053,6 +3053,22 @@ If you block this user, the following features will be restricted.
static var tabTitle: String { static var tabTitle: String {
pick(ko: "랭킹", en: "Ranking", ja: "ランキング") 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 { enum MainContentAll {

View File

@@ -48,7 +48,7 @@ struct MainContentView: View {
onTapSeries: onTapSeries onTapSeries: onTapSeries
) )
case .ranking: case .ranking:
MainPlaceholderTabView(title: MainContentTab.ranking.title) MainContentRankingView(onTapContent: onTapContent)
case .all: case .all:
MainContentAllView( MainContentAllView(
viewModel: allViewModel, viewModel: allViewModel,

View File

@@ -0,0 +1,10 @@
import SwiftUI
struct MainContentAudioRankingEmptyStateView: View {
let message: String
var body: some View {
MainContentAudioEmptyStateView(message: message)
.background(Color.black)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
import Foundation
import Combine
final class MainContentRankingViewModel: ObservableObject {
private let repository = MainContentRankingRepository()
private var subscription = Set<AnyCancellable>()
@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<AudioRankingResponse>.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)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
import Foundation
import Combine
import CombineMoya
import Moya
final class MainContentRankingRepository {
private let api = MoyaProvider<MainContentRankingApi>()
func getAudioRankings() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getAudioRankings)
}
}

View File

@@ -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<AudioRankingItemResponse>
)
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<Response, MoyaError>`를 제공한다.
- 검증 기준:
- 실행 명령: `rg "/api/v2/audio/rankings|getAudioRankings|Authorization|requestPlain|AnyPublisher<Response, MoyaError>" 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<AudioRankingResponse>`를 디코딩한다.
- 성공 시 `ranking.items`는 서버 순서를 유지하되 최대 20개만 표시 대상으로 보관한다.
- API 실패/디코딩 실패 시 `I18n.MainContentRanking.loadFailedMessage`를 표시한다.
- 빈 배열이면 `I18n.MainContentRanking.emptyStateMessage`를 표시한다.
- 검증 기준:
- 실행 명령: `rg "final class MainContentRankingViewModel|fetchRankings|ApiResponse<AudioRankingResponse>|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로 분기하도록 수정했다.

View File

@@ -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<AudioRankingResponse>` 디코딩을 우선한다.
### 7.2 Response model
```kotlin
data class AudioRankingResponse(
val showRankChange: Boolean,
val type: AudioRankingType,
val items: List<AudioRankingItemResponse>
)
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은 수정하지 않는다.