feat(home): 랭킹 API 기반 상태를 추가한다

This commit is contained in:
Yu Sung
2026-06-30 15:06:47 +09:00
parent a8680931fe
commit c4e83c3aef
16 changed files with 864 additions and 6 deletions

View File

@@ -213,6 +213,15 @@
401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */; };
401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */; };
401A3FBA2FEEAFA000A5BBA3 /* MainHomeRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76506E56D017236824FE9B8F /* MainHomeRecommendationViewModel.swift */; };
401B3F012FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0A2FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift */; };
401B3F022FEEF10000A5BBA3 /* MainHomeRankingApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0B2FEEF10000A5BBA3 /* MainHomeRankingApi.swift */; };
401B3F032FEEF10000A5BBA3 /* MainHomeRankingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */; };
401B3F042FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */; };
401B3F052FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0A2FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift */; };
401B3F062FEEF10000A5BBA3 /* MainHomeRankingApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0B2FEEF10000A5BBA3 /* MainHomeRankingApi.swift */; };
401B3F072FEEF10000A5BBA3 /* MainHomeRankingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */; };
401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */; };
401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */; };
401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */; };
@@ -1750,6 +1759,10 @@
401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationView.swift; sourceTree = "<group>"; };
401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingView.swift; sourceTree = "<group>"; };
401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingView.swift; sourceTree = "<group>"; };
401B3F0A2FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeCreatorRankingResponse.swift; sourceTree = "<group>"; };
401B3F0B2FEEF10000A5BBA3 /* MainHomeRankingApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingApi.swift; sourceTree = "<group>"; };
401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingRepository.swift; sourceTree = "<group>"; };
401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingViewModel.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>"; };
401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeActiveCreatorSection.swift; sourceTree = "<group>"; };
@@ -2742,11 +2755,31 @@
401A3FB82FEEAFA000A5BBA3 /* Ranking */ = {
isa = PBXGroup;
children = (
401B3F0E2FEEF10000A5BBA3 /* Models */,
401B3F0F2FEEF10000A5BBA3 /* Repository */,
401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */,
401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */,
);
path = Ranking;
sourceTree = "<group>";
};
401B3F0E2FEEF10000A5BBA3 /* Models */ = {
isa = PBXGroup;
children = (
401B3F0A2FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift */,
);
path = Models;
sourceTree = "<group>";
};
401B3F0F2FEEF10000A5BBA3 /* Repository */ = {
isa = PBXGroup;
children = (
401B3F0B2FEEF10000A5BBA3 /* MainHomeRankingApi.swift */,
401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */,
);
path = Repository;
sourceTree = "<group>";
};
401A3FB92FEEAFA000A5BBA3 /* Following */ = {
isa = PBXGroup;
children = (
@@ -5009,9 +5042,6 @@
);
dependencies = (
);
fileSystemSynchronizedGroups = (
401A3B942FEE61E000A5BBA3 /* docs */,
);
name = SodaLive;
packageProductDependencies = (
40B28A1F2A83775F008BE463 /* FirebaseAnalytics */,
@@ -5055,9 +5085,6 @@
);
dependencies = (
);
fileSystemSynchronizedGroups = (
401A3B942FEE61E000A5BBA3 /* docs */,
);
name = "SodaLive-dev";
packageProductDependencies = (
40B28A292A83779E008BE463 /* FirebaseAnalytics */,
@@ -5358,10 +5385,14 @@
400EA78F2A89F119004992CF /* LiveCancelDialog.swift in Sources */,
401A3F9E2FEEAFA000A5BBA3 /* MainHomeRecommendationApi.swift in Sources */,
401A3F9F2FEEAFA000A5BBA3 /* MainHomeRecommendationResponse.swift in Sources */,
401B3F012FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift in Sources */,
401B3F022FEEF10000A5BBA3 /* MainHomeRankingApi.swift in Sources */,
401B3F032FEEF10000A5BBA3 /* MainHomeRankingRepository.swift in Sources */,
401A3FA02FEEAFA000A5BBA3 /* FollowRecommendedCreatorsRequest.swift in Sources */,
401A3FA12FEEAFA000A5BBA3 /* MainHomeRecommendationRepository.swift in Sources */,
401A3FA22FEEAFA000A5BBA3 /* RecommendedActivityType.swift in Sources */,
401A3FBA2FEEAFA000A5BBA3 /* MainHomeRecommendationViewModel.swift in Sources */,
401B3F042FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
401A3FA82FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FA92FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
401A3FC22FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
@@ -6114,6 +6145,7 @@
38D73A7B6BFB9A984834984B /* TextTabBar.swift in Sources */,
372837FE9857C16B6B5087FF /* TitleBar.swift in Sources */,
BA2B76FDCAF81D15E2D4E258 /* MainHomeRecommendationViewModel.swift in Sources */,
401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
401A3FC32FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,
@@ -6151,6 +6183,9 @@
400EA7902A89F119004992CF /* LiveCancelDialog.swift in Sources */,
401A3FA32FEEAFA000A5BBA3 /* MainHomeRecommendationApi.swift in Sources */,
401A3FA42FEEAFA000A5BBA3 /* MainHomeRecommendationResponse.swift in Sources */,
401B3F052FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift in Sources */,
401B3F062FEEF10000A5BBA3 /* MainHomeRankingApi.swift in Sources */,
401B3F072FEEF10000A5BBA3 /* MainHomeRankingRepository.swift in Sources */,
401A3FA52FEEAFA000A5BBA3 /* FollowRecommendedCreatorsRequest.swift in Sources */,
401A3FA62FEEAFA000A5BBA3 /* MainHomeRecommendationRepository.swift in Sources */,
401A3FA72FEEAFA000A5BBA3 /* RecommendedActivityType.swift in Sources */,
@@ -6896,6 +6931,7 @@
7A1DDAAE227BF176BDEF1CB9 /* TextTabBar.swift in Sources */,
5EEE98FE0EC4039761C604EE /* TitleBar.swift in Sources */,
A4EB669DFFFB64681E045EF3 /* MainHomeRecommendationViewModel.swift in Sources */,
401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
401A3FC42FEEB00100A5BBA3 /* MainHomeLiveSection.swift in Sources */,

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_rank_caret_decrease.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_rank_caret_increase.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_rank_caret_stay.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_rank_new.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 B

View File

@@ -3042,6 +3042,16 @@ If you block this user, the following features will be restricted.
}
}
enum HomeRanking {
static var emptyStateMessage: String {
pick(
ko: "순위 집계 중입니다.\n잠시 후 다시 시도해 주세요.",
en: "Rankings are being calculated.\nPlease try again later.",
ja: "ランキングを集計中です。\nしばらくしてからもう一度お試しください。"
)
}
}
enum HomeRecommendation {
static var activityLive: String {
pick(ko: "라이브", en: "Live", ja: "ライブ")

View File

@@ -0,0 +1,63 @@
import Foundation
import Combine
final class MainHomeRankingViewModel: ObservableObject {
private let repository = MainHomeRankingRepository()
private var subscription = Set<AnyCancellable>()
@Published var isLoading = false
@Published var items = [MainHomeCreatorRankingItem]()
@Published var showRankChange = false
@Published var emptyMessage = ""
@Published var hasLoaded = false
func fetchRankings() {
isLoading = true
emptyMessage = ""
repository.getCreatorRankings()
.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.emptyMessage = I18n.HomeRanking.emptyStateMessage
self.hasLoaded = true
self.isLoading = false
}
} receiveValue: { [weak self] response in
guard let self else { return }
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<MainHomeCreatorRankingResponse>.self, from: responseData)
if let data = decoded.data, decoded.success, !data.items.isEmpty {
self.items = Array(data.items.prefix(20))
self.showRankChange = data.showRankChange
self.emptyMessage = ""
} else {
self.items = []
self.showRankChange = false
self.emptyMessage = I18n.HomeRanking.emptyStateMessage
}
} catch {
ERROR_LOG(error.localizedDescription)
self.items = []
self.showRankChange = false
self.emptyMessage = I18n.HomeRanking.emptyStateMessage
}
self.hasLoaded = true
self.isLoading = false
}
.store(in: &subscription)
}
}

View File

@@ -0,0 +1,17 @@
import Foundation
struct MainHomeCreatorRankingResponse: Decodable {
let showRankChange: Bool
let items: [MainHomeCreatorRankingItem]
}
struct MainHomeCreatorRankingItem: Decodable, Identifiable {
let rank: Int
let rankChange: Int?
let isNew: Bool
let creatorId: Int
let nickname: String
let profileImageUrl: String?
var id: Int { creatorId }
}

View File

@@ -0,0 +1,38 @@
import Foundation
import Moya
enum MainHomeRankingApi {
case getCreatorRankings
}
extension MainHomeRankingApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getCreatorRankings:
return "/api/v2/home/rankings/creators"
}
}
var method: Moya.Method {
switch self {
case .getCreatorRankings:
return .get
}
}
var task: Moya.Task {
switch self {
case .getCreatorRankings:
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 MainHomeRankingRepository {
private let api = MoyaProvider<MainHomeRankingApi>()
func getCreatorRankings() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getCreatorRankings)
}
}

View File

@@ -0,0 +1,350 @@
# 메인 홈 랭킹 탭 구현 계획
## 기준 문서
- PRD: `docs/20260630_메인_홈_랭킹_탭/prd.md`
- 코드 스타일: `docs/agent-guides/code-style.md`
- 빌드/검증: `docs/agent-guides/build-test-verification.md`
- Figma 전체 화면: `24:5654`
- Figma 1위 카드: `24:5659`
- Figma 2~7위 카드: `24:5661`
- Figma 8~10위 카드: `24:5667`
- Figma 11위 이상 row: `24:5670`
- Figma 상승 badge: `24:5674`
- Figma 하락 badge: `24:5675`
- Figma 변동 없음 badge 참고: `24:5678`
## 구현 원칙
- `MainHomeView`는 홈 상단 title bar, `추천`/`랭킹`/`팔로잉` Text tab shell만 담당한다.
- 랭킹 탭의 API, 모델, ViewModel, 세부 UI는 `SodaLive/Sources/V2/Main/Home/Ranking/**` 아래에 둔다.
- 기존 추천 탭 API/ViewModel/Moya 패턴을 따르고, 기존 구 홈 API/랭킹 구현은 수정하지 않는다.
- Capsule Tab bar와 랭킹 필터는 구현하지 않는다.
- 서버가 내려준 `items[].rank`를 화면 순위로 사용하고 클라이언트에서 index 기반 순위를 재계산하지 않는다.
- item은 최대 20개로 취급한다.
- 1~10위 카드는 모양이 동일하므로 `MainHomeRankingCard` 하나를 재사용하고, 1열/2열/3열 차이는 부모 grid/layout에서 처리한다.
- 11위 이상은 row 방식으로 모양이 다르므로 `MainHomeRankingRow`로 분리한다.
- 카드/row 컴포넌트의 루트 레이아웃은 고정 숫자 width/height로 제한하지 않고, grid column, parent width, `aspectRatio`, relative sizing 기반으로 화면 크기에 대응한다.
- 랭킹 페이지 노출 자체는 로그인 guard 없이 허용하고, item 탭으로 상세 진입할 때만 기존 creator 상세 guard를 재사용한다.
- 외부 라이브러리는 추가하지 않는다.
## 주요 대상 파일
### 생성
- `SodaLive/Sources/V2/Main/Home/Ranking/Models/MainHomeCreatorRankingResponse.swift`
- `SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingApi.swift`
- `SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingRepository.swift`
- `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingViewModel.swift`
- `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankChangeBadge.swift`
- `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingCard.swift`
- `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingRow.swift`
- `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingEmptyStateView.swift`
### 수정
- `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingView.swift`
- `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- `SodaLive/Sources/I18n/I18n.swift`
- 필요 시 `SodaLive.xcodeproj/project.pbxproj`
### 확인
- `SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift`
- `SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationRepository.swift`
- `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.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: PRD와 기존 랭킹 placeholder 확인**
- 대상 파일:
- 확인: `docs/20260630_메인_홈_랭킹_탭/prd.md`
- 확인: `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingView.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- 작업 내용:
- PRD 성공 기준과 제외 범위를 다시 읽고 구현 범위를 확정한다.
- `MainHomeRankingView`가 placeholder인지 확인한다.
- `MainHomeView`가 랭킹 탭에 `onTapCreator`를 아직 전달하지 않는지 확인한다.
- 검증 기준:
- 실행 명령: `sed -n '1,140p' SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingView.swift && sed -n '1,120p' SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- 기대 결과: 랭킹 탭은 placeholder이고, `MainHomeView`는 탭 shell 역할만 담당한다.
- [x] **Task 1.2: 순위 변화 아이콘 asset 확인**
- 대상 파일:
- 확인: `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`
- 작업 내용:
- PRD에서 지정한 순위 변화 아이콘 asset이 존재하는지 확인한다.
- asset이 없으면 구현을 진행하지 말고 PRD/사용자 확인을 먼저 받는다.
- 검증 기준:
- 실행 명령: `find SodaLive/Resources/Assets.xcassets/v2 -maxdepth 2 -name 'ic_rank_*' | sort`
- 기대 결과: `ic_rank_caret_decrease`, `ic_rank_caret_increase`, `ic_rank_caret_stay`, `ic_rank_new` imageset이 모두 확인된다.
### Phase 2: API와 응답 모델
- [x] **Task 2.1: 랭킹 응답 모델 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Ranking/Models/MainHomeCreatorRankingResponse.swift`
- 작업 내용:
- `MainHomeCreatorRankingResponse``MainHomeCreatorRankingItem`을 PRD 필드명 그대로 만든다.
- `profileImageUrl`은 optional로 둔다.
- `MainHomeCreatorRankingItem``Identifiable`을 채택하고 `id``creatorId`를 사용한다.
- 검증 기준:
- 실행 명령: `rg "struct MainHomeCreatorRankingResponse|struct MainHomeCreatorRankingItem|showRankChange|rankChange|profileImageUrl|var id" SodaLive/Sources/V2/Main/Home/Ranking/Models/MainHomeCreatorRankingResponse.swift`
- 기대 결과: PRD의 응답 필드와 `Identifiable` id 기준이 확인된다.
- [x] **Task 2.2: 랭킹 API endpoint 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingApi.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift`
- 작업 내용:
- `MainHomeRankingApi``TargetType`으로 만든다.
- case는 `getCreatorRankings` 하나만 둔다.
- path는 `/api/v2/home/rankings/creators`, method는 `.get`, task는 `.requestPlain`로 둔다.
- headers는 기존 V2 홈 추천 API와 동일하게 `Authorization: Bearer {token}` 패턴을 사용한다.
- 검증 기준:
- 실행 명령: `rg "/api/v2/home/rankings/creators|getCreatorRankings|Authorization|requestPlain" SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingApi.swift`
- 기대 결과: 신규 endpoint와 인증 헤더가 랭킹 전용 API에만 존재한다.
- [x] **Task 2.3: 랭킹 Repository 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingRepository.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationRepository.swift`
- 작업 내용:
- `MainHomeRankingRepository`를 만들고 `MoyaProvider<MainHomeRankingApi>`를 사용한다.
- `getCreatorRankings() -> AnyPublisher<Response, MoyaError>` 메서드를 제공한다.
- 검증 기준:
- 실행 명령: `rg "final class MainHomeRankingRepository|MoyaProvider<MainHomeRankingApi>|getCreatorRankings\\(\\)|AnyPublisher<Response, MoyaError>" SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingRepository.swift`
- 기대 결과: 추천 탭과 같은 Repository 패턴이 확인된다.
### Phase 3: ViewModel 상태와 데이터 로딩
- [x] **Task 3.1: 랭킹 ViewModel 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingViewModel.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift`
- 작업 내용:
- `MainHomeRankingViewModel``ObservableObject`로 만든다.
- `isLoading`, `items`, `showRankChange`, `emptyMessage`, `hasLoaded` 상태를 둔다.
- `fetchRankings()`에서 `ApiResponse<MainHomeCreatorRankingResponse>`를 디코딩한다.
- 성공 시 `items`는 서버 순서를 유지하되 최대 20개만 보관한다.
- 빈 배열 또는 API 실패/디코딩 실패 시 `emptyMessage``I18n.HomeRanking.emptyStateMessage`를 설정한다.
- 실패 시 toast 대신 empty state를 표시한다.
- 검증 기준:
- 실행 명령: `rg "final class MainHomeRankingViewModel|fetchRankings|ApiResponse<MainHomeCreatorRankingResponse>|prefix\\(20\\)|emptyStateMessage|hasLoaded" SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingViewModel.swift`
- 기대 결과: 최대 20개 처리, empty state 처리, API 디코딩 로직이 확인된다.
- [x] **Task 3.2: 랭킹 empty state I18n 추가**
- 대상 파일:
- 수정: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- `I18n.HomeRanking.emptyStateMessage`를 추가한다.
- ko는 `순위 집계 중입니다.\n잠시 후 다시 시도해 주세요.`로 둔다.
- en/ja는 기능 검증을 막지 않는 자연스러운 대응 문구로 추가한다.
- 검증 기준:
- 실행 명령: `rg "enum HomeRanking|emptyStateMessage|순위 집계 중입니다|잠시 후 다시 시도해 주세요" SodaLive/Sources/I18n/I18n.swift`
- 기대 결과: 랭킹 empty state 문구가 I18n에 존재한다.
### Phase 4: 순위 변화 UI 컴포넌트
- [ ] **Task 4.1: 순위 변화 badge 컴포넌트 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankChangeBadge.swift`
- 작업 내용:
- 입력값은 `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`이면 아무것도 표시하지 않는다.
- Preview에는 상승, 하락, 변동 없음, 신규 진입, `showRankChange == false`, `rankChange == nil && isNew == false` 상태를 모두 배치해 한 화면에서 UI 차이를 확인할 수 있게 한다.
- 검증 기준:
- 실행 명령: `rg "struct MainHomeRankChangeBadge|ic_rank_new|ic_rank_caret_increase|ic_rank_caret_decrease|ic_rank_caret_stay|abs\\(|PreviewProvider|showRankChange: false|rankChange: nil|isNew: true|rankChange: 0" SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankChangeBadge.swift`
- 기대 결과: PRD의 순위 변화 표시 규칙, 아이콘명, 조건별 Preview가 모두 확인된다.
### Phase 5: 순위별 랭킹 UI 컴포넌트
- [ ] **Task 5.1: 1~10위 공용 카드 컴포넌트 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingCard.swift`
- 확인: `SodaLive/Sources/V2/Component/Creator/CreatorProfileItem.swift`
- 확인: `SodaLive/Sources/Home/HomeCreatorRankingItemView.swift`
- 작업 내용:
- `rank >= 1 && rank <= 10` 카드형 item을 모두 담당하는 공용 컴포넌트로 만든다.
- Figma 기준으로 1위, 2~7위, 8~10위 카드는 크기만 다르고 모양은 동일하므로 별도 카드 파일을 만들지 않는다.
- 1열/2열/3열 배치와 카드 크기는 부모 `MainHomeRankingView`의 grid/layout이 결정한다.
- `DownsampledKFImage``profileImageUrl`을 표시한다.
- 카드 하단에 dim gradient를 적용하고 nickname을 한 줄 말줄임으로 표시한다.
- rank 숫자는 `item.rank`를 사용한다.
- 순위 변화 표시는 `MainHomeRankChangeBadge`를 사용한다.
- 카드 tap 시 `onTapCreator(item.creatorId)`를 호출한다.
- 루트 container에 고정 숫자 `frame(width:)`, `frame(height:)`를 사용하지 않는다.
- 전체 카드 비율은 부모 width 기반 `aspectRatio` 또는 container-relative 제약으로 잡는다.
- Preview에는 1위 크기, 2열 크기, 3열 크기 예시를 함께 배치한다.
- Preview에는 상승, 하락, 변동 없음, 신규 진입, 순위 변화 숨김 상태를 모두 배치해 한 화면에서 UI 차이를 확인할 수 있게 한다.
- 검증 기준:
- 실행 명령: `rg "struct MainHomeRankingCard|DownsampledKFImage|LinearGradient|item.rank|MainHomeRankChangeBadge|onTapCreator\\(item.creatorId\\)|aspectRatio|PreviewProvider|rankChange: 5|rankChange: -|rankChange: 0|isNew: true|showRankChange: false" SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingCard.swift`
- 기대 결과: 1~10위 카드가 하나의 공용 컴포넌트로 구현되고, 서버 rank와 공용 badge를 사용하며, 전체 크기는 부모 layout에 의해 결정되고 조건별 Preview가 확인된다.
- [ ] **Task 5.2: 11위 이상 row 컴포넌트 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingRow.swift`
- 작업 내용:
- `rank >= 11` item을 row 방식으로 표시한다.
- rank 숫자, profile image, nickname, 순위 변화 badge를 Figma row 구조에 맞춰 배치한다.
- rank 숫자는 `item.rank`를 사용한다.
- 순위 변화 표시는 `MainHomeRankChangeBadge`를 사용한다.
- row tap 시 `onTapCreator(item.creatorId)`를 호출한다.
- 루트 container에 고정 숫자 `frame(width:)`, `frame(height:)`를 사용하지 않는다.
- row의 전체 높이는 고정 숫자로 박지 않고 내부 padding, image aspect ratio, parent width 기반 제약으로 구성한다.
- Preview에는 11위 이상 row의 상승, 하락, 변동 없음, 신규 진입, 순위 변화 숨김 상태를 모두 배치해 한 화면에서 UI 차이를 확인할 수 있게 한다.
- 검증 기준:
- 실행 명령: `rg "struct MainHomeRankingRow|DownsampledKFImage|item.rank|MainHomeRankChangeBadge|onTapCreator\\(item.creatorId\\)|aspectRatio|PreviewProvider|rankChange: 5|rankChange: -|rankChange: 0|isNew: true|showRankChange: false" SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingRow.swift`
- 기대 결과: row형 순위 UI가 서버 rank와 공용 badge를 사용하고, 전체 크기는 반응형 제약으로 구성되며 조건별 Preview가 확인된다.
- [ ] **Task 5.3: empty state 컴포넌트 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingEmptyStateView.swift`
- 작업 내용:
- `I18n.HomeRanking.emptyStateMessage`를 중앙 또는 랭킹 콘텐츠 영역 안에 표시한다.
- 배경은 검정, 텍스트는 기존 V2 empty state 관례와 맞춘다.
- 검증 기준:
- 실행 명령: `rg "struct MainHomeRankingEmptyStateView|I18n.HomeRanking.emptyStateMessage|Color.black" SodaLive/Sources/V2/Main/Home/Ranking/Components/MainHomeRankingEmptyStateView.swift`
- 기대 결과: empty/API 실패 공통 문구 표시 컴포넌트가 확인된다.
### Phase 6: 랭킹 탭 화면 조립과 상세 진입 연결
- [ ] **Task 6.1: MainHomeRankingView 구현**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingView.swift`
- 작업 내용:
- placeholder를 제거한다.
- `@StateObject private var viewModel = MainHomeRankingViewModel()`를 사용한다.
- 최초 `onAppear`에서 `hasLoaded == false`일 때만 `fetchRankings()`를 호출한다.
- loading, empty, content 상태를 분기한다.
- content 상태에서는 1위 단독, 2~7위 2열, 8~10위 3열, 11위 이상 row로 조립한다.
- 1~10위는 공용 `MainHomeRankingCard`, 11위 이상은 `MainHomeRankingRow`를 사용한다.
- 2열/3열 grid는 고정 숫자 card width가 아니라 `GridItem(.flexible())` 기반 column으로 구성한다.
- `MainHomeRankingView`에서 카드/row 컴포넌트의 루트 크기를 고정 숫자로 강제하지 않는다.
- Capsule Tab bar는 추가하지 않는다.
- 생성한 카드/row 컴포넌트에 `showRankChange``onTapCreator`를 전달한다.
- 검증 기준:
- 실행 명령: `rg "MainHomeRankingViewModel|fetchRankings|MainHomeRankingCard|MainHomeRankingRow|MainHomeRankingEmptyStateView|GridItem\\(\\.flexible\\(\\)\\)|onTapCreator|MainPlaceholderTabView|CapsuleTabBar" SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingView.swift`
- 기대 결과: `MainPlaceholderTabView``CapsuleTabBar`는 검색되지 않고, 랭킹 ViewModel/공용 카드/row 조립과 flexible grid가 확인된다.
- [ ] **Task 6.2: MainHomeView에서 creator tap callback 전달**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- 확인: `SodaLive/Sources/V2/Main/MainView.swift`
- 작업 내용:
- 랭킹 탭에서 `MainHomeRankingView(onTapCreator: onTapCreator)`를 호출하도록 변경한다.
- `MainView`의 기존 `handleRecommendationCreatorTap` 로그인 guard 흐름을 그대로 재사용한다.
- 랭킹 페이지 노출 자체에는 별도 guard를 추가하지 않는다.
- 검증 기준:
- 실행 명령: `rg "MainHomeRankingView\\(onTapCreator: onTapCreator\\)|handleRecommendationCreatorTap|setAppStep\\(step: \\.login\\)|creatorDetail" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: 랭킹 item tap이 기존 creator 상세 guard callback으로 연결된다.
### Phase 7: 프로젝트 연결과 전체 검증
- [ ] **Task 7.1: Xcode 프로젝트 포함 여부 확인**
- 대상 파일:
- 확인/필요 시 수정: `SodaLive.xcodeproj/project.pbxproj`
- 확인: `SodaLive/Sources/V2/Main/Home/Ranking/**`
- 작업 내용:
- 신규 Swift 파일이 빌드 대상에 포함되는지 확인한다.
- 현재 프로젝트 구조에서 파일 시스템 동기화가 자동이 아니면 `project.pbxproj`에 필요한 file reference/build phase를 추가한다.
- 검증 기준:
- 실행 명령: `rg "MainHomeCreatorRankingResponse|MainHomeRankingApi|MainHomeRankingRepository|MainHomeRankingViewModel|MainHomeRankChangeBadge|MainHomeRankingCard|MainHomeRankingRow|MainHomeRankingEmptyStateView" SodaLive.xcodeproj/project.pbxproj SodaLive/Sources/V2/Main/Home/Ranking`
- 기대 결과: 신규 Swift 파일이 소스 경로와 필요 시 Xcode 프로젝트에서 확인된다.
- [ ] **Task 7.2: 정적 검색 검증**
- 대상 파일:
- 확인: `SodaLive/Sources/V2/Main/Home/Ranking/**`
- 확인: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- 확인: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- endpoint, 아이콘명, empty state, 공용 카드/row 컴포넌트 분리, Capsule Tab bar 제외, rank index 미사용 여부를 검색으로 확인한다.
- 카드/row 컴포넌트의 루트 container가 고정 숫자 `frame(width:)`/`frame(height:)`로 제한되지 않는지는 코드 리뷰로 확인한다. 단, 아이콘/내부 이미지의 작은 고정 크기는 이 금지 대상이 아니다.
- 검증 기준:
- 실행 명령: `rg "/api/v2/home/rankings/creators|ic_rank_caret_decrease|ic_rank_caret_increase|ic_rank_caret_stay|ic_rank_new|순위 집계 중입니다|MainHomeRankingCard|MainHomeRankingRow|CapsuleTabBar|enumerated\\(\\)" SodaLive/Sources/V2/Main/Home/Ranking SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/I18n/I18n.swift`
- 기대 결과: endpoint/아이콘/문구/공용 카드/row 컴포넌트는 확인되고, 랭킹 UI에서 `CapsuleTabBar`와 rank 계산용 `enumerated()` 사용은 없어야 한다.
- [ ] **Task 7.3: 빌드 검증**
- 대상 파일:
- 확인: `SodaLive.xcworkspace`
- 확인: `SodaLive.xcodeproj`
- 작업 내용:
- 공식 빌드 명령으로 컴파일 오류를 확인한다.
- 테스트 번들 타깃이 확인되지 않는 저장소 상태이므로, 우선 Debug build를 필수 검증으로 둔다.
- 검증 기준:
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: build succeeded
- [ ] **Task 7.4: 수동 QA 체크**
- 대상 파일:
- 확인: `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingView.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Ranking/Components/**`
- 작업 내용:
- 랭킹 탭 진입 시 API가 1회 호출되는지 확인한다.
- 1위, 2~7위, 8~10위, 11위 이상 배치가 Figma 기준과 맞는지 확인한다.
- 1~10위가 `MainHomeRankingCard` 하나를 재사용하고, 11위 이상만 `MainHomeRankingRow`를 사용하는지 확인한다.
- 카드/row 컴포넌트의 루트 레이아웃이 화면 폭에 따라 자연스럽게 대응하는지 확인한다.
- Phase 4/5 컴포넌트 Preview에서 상승, 하락, 변동 없음, 신규 진입, 순위 변화 숨김 상태가 한 화면에서 모두 보이는지 확인한다.
- `showRankChange == false`일 때 순위 변화 UI가 숨겨지는지 확인한다.
- `isNew == true`일 때 `ic_rank_new`가 표시되는지 확인한다.
- `rankChange > 0`, `< 0`, `== 0` 각각 increase/decrease/stay 아이콘 규칙을 확인한다.
- 빈 배열 또는 API 실패 시 empty state 문구가 표시되는지 확인한다.
- 랭킹 item tap 시 로그인 guard 이후 creator 상세로 이동하는지 확인한다.
- 검증 기준:
- 수동 확인: PRD Success Criteria 전 항목을 체크한다.
## 검증 기록
- 2026-06-30 문서 생성:
- 목적: `prd.md`를 기반으로 메인 홈 랭킹 탭 구현 전 plan-task 문서 생성
- 확인한 기준 문서:
- `docs/20260630_메인_홈_랭킹_탭/prd.md`
- `docs/agent-guides/documentation-policy.md`
- `docs/agent-guides/build-test-verification.md`
- `docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md`
- 확인한 코드:
- `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingView.swift`가 placeholder 상태임
- `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`가 랭킹 탭에 `MainHomeRankingView()`를 조합 중임
- `SodaLive/Sources/V2/Main/Home/Recommendation/**`의 API/Repository/ViewModel 패턴
- `SodaLive/Sources/V2/Main/MainView.swift`의 기존 creator 상세 guard callback
- `SodaLive/Resources/Assets.xcassets/v2/ic_rank_*` 아이콘 asset 존재
- 2026-06-30 문서 보완:
- 목적: 순위별 UI 검증 Task를 명확히 하고, 카드/row 루트 레이아웃의 고정 숫자 width/height 금지 제약을 반영
- 변경 내용:
- PRD에 1~10위 공용 카드와 11위 이상 row 분리 기준 추가
- PRD에 카드/row 컴포넌트 루트 레이아웃의 반응형 제약 기준 추가
- plan-task Phase 5를 `MainHomeRankingCard`, `MainHomeRankingRow` Task로 정리
- Phase 4/5 Preview에서 상승, 하락, 변동 없음, 신규 진입, 순위 변화 숨김 상태를 모두 확인하도록 검증 기준 추가
- 2026-06-30 문서 보완:
- 목적: Figma상 1~10위 카드는 크기만 다르고 모양이 동일하므로 카드 컴포넌트를 하나로 통합
- 변경 내용:
- 순위 구간별로 나뉘어 있던 카드 컴포넌트 계획을 제거
- `MainHomeRankingCard` 단일 카드 컴포넌트와 부모 grid 기반 1열/2열/3열 배치로 계획 수정
- 2026-06-30 Phase 1~3 진행:
- 목적: 랭킹 탭 API 연동을 위한 기준점 확인, 응답 모델/API/Repository/ViewModel/I18n 추가
- 확인한 기준:
- `MainHomeRankingView.swift`는 placeholder 상태 유지
- `MainHomeView.swift`는 랭킹 탭에 `onTapCreator`를 아직 전달하지 않는 Phase 1 기준 상태 유지
- `ic_rank_caret_decrease`, `ic_rank_caret_increase`, `ic_rank_caret_stay`, `ic_rank_new` asset 존재 확인
- 변경 내용:
- `MainHomeCreatorRankingResponse`, `MainHomeCreatorRankingItem` 추가
- `MainHomeRankingApi`, `MainHomeRankingRepository` 추가
- `MainHomeRankingViewModel` 추가
- `I18n.HomeRanking.emptyStateMessage` 추가
- 검증 메모:
- 현재 저장소 가이드상 테스트 번들 타깃이 확인되지 않아 신규 단위 테스트는 추가하지 않고 정적 검색 및 빌드 검증으로 확인

View File

@@ -0,0 +1,247 @@
# PRD: 메인 홈 랭킹 탭
## 1. Overview
메인 홈 화면의 `랭킹` 탭에서 신규 API `GET /api/v2/home/rankings/creators` 응답을 사용해 인기 크리에이터 랭킹을 표시한다.
기존 `MainHomeView`는 홈 상단 공통 shell과 `추천`/`랭킹`/`팔로잉` 탭 전환만 담당한다. 랭킹 탭의 API 호출, 상태 관리, 화면 조립은 `SodaLive/Sources/V2/Main/Home/Ranking/**` 하위에서 처리한다.
Figma 전체 화면의 3번째 줄에 있는 Capsule Tab bar는 이번 랭킹 탭 화면에 배치하지 않는다. 즉, `주간 인기`, `지금 뜨는 중`, `남성 인기`, `여성 인기` 필터 UI는 구현 범위에서 제외한다.
## 2. Problem
- 현재 `MainHomeRankingView`는 placeholder 상태라 홈의 `랭킹` 탭에서 실제 크리에이터 랭킹 데이터를 제공하지 못한다.
- 랭킹 화면은 순위별로 카드 크기와 배치 방식이 다르므로, 단순 리스트로 처리하면 Figma 기준 UI와 맞지 않는다.
- 서버는 순위 변화 표시 여부(`showRankChange`)와 각 item의 변화 값(`rankChange`, `isNew`)을 함께 내려주므로, 클라이언트가 이를 일관된 규칙으로 표현해야 한다.
- 신규 랭킹 API를 기존 추천 탭 API나 기존 구 홈 API에 섞으면 홈 탭별 책임이 흐려지고 회귀 위험이 커진다.
## 3. Goals
- `랭킹` 탭 진입 시 `GET /api/v2/home/rankings/creators`를 호출하고 응답 데이터로 화면을 구성한다.
- 랭킹 탭 전용 API, Repository, ViewModel, Response model을 `SodaLive/Sources/V2/Main/Home/Ranking/**` 아래에 둔다.
- Figma 기준으로 1위, 2~7위, 8~10위, 11위 이상 item의 배치와 시각 구조를 구분한다.
- `items[].rank` 값을 화면 순위로 사용하고 배열 index로 순위를 재계산하지 않는다.
- `showRankChange == true`일 때만 순위 변화 badge를 표시한다.
- `items[].isNew == true`이면 순위 변화 숫자 대신 `ic_rank_new` 아이콘을 표시한다.
- `items[].rankChange`가 양수이면 순위 상승, 음수이면 순위 하락으로 표시한다.
- 직전 공개 스냅샷이 없어 `showRankChange == false`이면 모든 item에서 순위 변화 UI를 숨긴다.
- 최대 item 개수는 20개로 고정한다.
- 크리에이터 item 탭 시 기존 크리에이터 상세 진입 흐름을 사용한다.
- Capsule Tab bar는 배치하지 않는다.
## 4. Non-Goals
- Figma 전체 화면 3번째 줄의 Capsule Tab bar를 구현하지 않는다.
- `주간 인기`, `지금 뜨는 중`, `남성 인기`, `여성 인기` 같은 랭킹 필터/세그먼트 기능은 구현하지 않는다.
- 이번 API 스펙에 없는 페이지네이션, 무한 스크롤, 정렬 변경, 성별 필터, 기간 필터를 추가하지 않는다.
- 추천 탭, 팔로잉 탭, 하단 메인 탭 구조를 변경하지 않는다.
- 기존 구 홈 화면의 `HomeCreatorRankingItemView`, `HomeTabViewModel.creatorRanking` 동작을 변경하지 않는다.
- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다.
- 서버가 내려주지 않는 팔로우 상태, 라이브 상태, 후원 정보, 카테고리 정보를 임의로 표시하지 않는다.
## 5. Target Users
- 홈에서 현재 인기 크리에이터를 빠르게 탐색하려는 사용자
- 크리에이터의 순위와 직전 공개 스냅샷 대비 변화 여부를 확인하려는 사용자
- 신규 진입 크리에이터를 랭킹 화면에서 구분해 보고 싶은 사용자
## 6. User Stories
- 사용자는 홈 `랭킹` 탭에서 인기 크리에이터 목록을 순위별 강조도에 맞게 보고 싶다.
- 사용자는 순위가 오른 크리에이터와 내려간 크리에이터를 구분하고 싶다.
- 사용자는 새로 랭킹에 진입한 크리에이터를 신규 진입 아이콘으로 확인하고 싶다.
- 사용자는 마음에 드는 크리에이터를 탭해 상세 화면으로 이동하고 싶다.
## 7. Core Requirements
### 7.1 랭킹 데이터 조회
#### API
- Method: `GET`
- Path: `/api/v2/home/rankings/creators`
- 인증: 기존 V2 홈 API 인증 헤더 패턴을 따른다.
- 응답 래퍼: 기존 관례대로 `ApiResponse<MainHomeCreatorRankingResponse>` 디코딩을 우선한다.
#### Response
```json
{
"showRankChange": true,
"items": [
{
"rank": 1,
"rankChange": 5,
"isNew": false,
"creatorId": 123,
"nickname": "creator",
"profileImageUrl": "https://cdn.example.com/profile.png"
},
{
"rank": 2,
"rankChange": null,
"isNew": true,
"creatorId": 456,
"nickname": "new creator",
"profileImageUrl": "https://cdn.example.com/profile-new.png"
}
]
}
```
#### Swift 모델 기준
```swift
struct MainHomeCreatorRankingResponse: Decodable {
let showRankChange: Bool
let items: [MainHomeCreatorRankingItem]
}
struct MainHomeCreatorRankingItem: Decodable, Identifiable {
let rank: Int
let rankChange: Int?
let isNew: Bool
let creatorId: Int
let nickname: String
let profileImageUrl: String?
var id: Int { creatorId }
}
```
`profileImageUrl`은 서버 예시에서 문자열로 내려오지만, 이미지가 없을 수 있는 운영 데이터를 고려해 Swift 모델에서는 optional로 선언한다. 이미지가 비어 있거나 로드 실패하면 기존 프로젝트의 기본 프로필 이미지 처리 패턴을 따른다.
### 7.2 순위 변화 표시 규칙
- `showRankChange``items`와 같은 레벨에 내려오는 화면 제어 값이다.
- `showRankChange == false`이면 모든 item에서 `rankChange`, `isNew` 값과 관계없이 순위 변화 UI를 표시하지 않는다.
- `showRankChange == true && item.isNew == true`이면 `ic_rank_new` 아이콘을 표시한다.
- `isNew == true`인 item은 비교 가능한 이전 순위가 없으므로 `rankChange == nil`로 내려오는 것을 정상으로 취급한다.
- `showRankChange == true && item.isNew == false && item.rankChange != nil`이면 순위 변화 숫자 badge를 표시한다.
- `rankChange > 0`이면 순위 상승으로 표시한다. 예: 직전 10위, 최신 5위는 `rankChange == 5`.
- `rankChange < 0`이면 순위 하락으로 표시한다. 예: 직전 1위, 최신 10위는 `rankChange == -9`.
- `rankChange == 0`이면 숫자 `0`은 표시하지 않고 `ic_rank_caret_stay` 아이콘으로 변동 없음 상태를 표시한다.
- 숫자 badge에는 변화량의 절댓값을 표시하고, 상승/하락 방향은 전용 caret 아이콘으로 구분한다.
- 상승 badge는 Figma `node-id=24-5674` 기준으로 표시하고 `ic_rank_caret_increase` 아이콘을 사용한다.
- 하락 badge는 Figma `node-id=24-5675` 기준으로 표시하고 `ic_rank_caret_decrease` 아이콘을 사용한다.
- 신규 진입 표시는 텍스트 `New` 대신 `ic_rank_new` 아이콘을 사용한다.
- `rankChange == nil && isNew == false`이면 순위 변화 badge를 표시하지 않는다.
직전 공개 스냅샷이 없을 때 서버 응답은 아래처럼 처리된다는 전제를 둔다.
- `showRankChange == false`
- 각 item의 `rankChange == nil`
- 각 item의 `isNew == false`
### 7.3 랭킹 item 레이아웃
Figma 참조:
- 전체 화면: `node-id=24-5654`
- 1위 카드: `node-id=24-5659`
- 2~7위 카드: `node-id=24-5661`
- 8~10위 카드: `node-id=24-5667`
- 11위 이상 row: `node-id=24-5670`
배치 규칙:
- `rank == 1`: 한 줄에 1개, 화면 width 기준 큰 카드로 표시한다.
- `rank >= 2 && rank <= 7`: 한 줄에 2개 grid 카드로 표시한다.
- `rank >= 8 && rank <= 10`: 한 줄에 3개 grid 카드로 표시한다.
- `rank >= 11`: row 방식으로 표시한다.
- `rank == 1`, `rank >= 2 && rank <= 7`, `rank >= 8 && rank <= 10`은 크기와 배치만 다르고 카드 모양은 동일하므로 하나의 카드 컴포넌트를 재사용한다.
- 1열/2열/3열 배치는 카드 컴포넌트가 아니라 부모 grid/layout이 결정한다.
- `rank >= 11`은 row 방식으로 모양이 다르므로 카드와 별도 row 컴포넌트로 분리한다.
- 카드/row 컴포넌트의 전체 레이아웃 크기는 고정 숫자 width/height로 제한하지 않고, 화면 크기에 대응할 수 있도록 부모 width, grid column, aspect ratio, relative sizing 기반으로 구성한다.
- API 응답 item은 최대 20개로 취급한다.
- item은 서버가 내려준 `items` 순서를 기본으로 렌더링하되, 화면에 표시하는 순위 숫자는 `item.rank`를 사용한다.
- 서버 순서와 `item.rank`가 불일치해도 클라이언트에서 임의 재정렬하지 않는다. 불일치는 서버 데이터 문제로 보고 QA/로그 확인 대상으로 남긴다.
- 카드/row는 전체적으로 검정 배경 위에 배치한다.
- profile image는 카드/row의 주요 이미지로 사용한다.
- nickname은 Figma의 `텍스트` 위치에 표시한다.
- rank 숫자는 Figma의 rank number 위치와 크기 계층을 따른다.
- 카드형 item은 이미지 하단에 dim gradient를 적용해 nickname 가독성을 확보한다.
### 7.4 화면 상태
- 최초 진입 또는 탭 전환으로 랭킹 탭이 처음 노출될 때 API를 호출한다.
- 동일 화면 생명주기 안에서 이미 성공적으로 로드한 데이터가 있으면 불필요한 중복 호출은 피한다.
- `items`가 빈 배열이면 empty state를 표시한다.
- API 실패 시에도 empty state 문구를 표시한다.
- empty state 문구는 `순위 집계 중입니다.\n잠시 후 다시 시도해 주세요.`로 표시한다.
- pull-to-refresh가 기존 `MainHomeView` 또는 추천 탭에 있다면 같은 패턴으로 재조회한다. 기존 패턴이 없다면 이번 범위에서 새로 추가하지 않는다.
### 7.5 상세 진입
- 랭킹 item 탭 시 `creatorId`로 기존 크리에이터 상세 화면에 진입한다.
- 랭킹 조회 자체는 로그인, 본인인증, 민감 콘텐츠 설정으로 막지 않는다.
- 랭킹 페이지 노출에는 로그인 guard가 필요하지 않다.
- 터치 등의 사용자 액션으로 상세 페이지에 진입할 때는 로그인 guard가 필요하다.
- 상세 진입 guard는 기존 메인/추천 탭에서 사용하는 guard 패턴을 재사용한다.
- 상세 진입 전 서버 응답에 없는 성인/민감 콘텐츠 여부를 클라이언트에서 임의 추정하지 않는다.
## 8. UX / UI Expectations
### 8.1 포함하는 UI
- 홈 공통 title bar
- 홈 상단 `추천`/`랭킹`/`팔로잉` Text tab bar
- 선택된 `랭킹` 탭 콘텐츠
- 크리에이터 랭킹 카드/row 목록
- 순위 변화 숫자 badge 또는 신규 진입 아이콘
- 하단 메인 tab bar는 기존 `MainView` 구조에서 유지
### 8.2 제외하는 UI
- Figma 전체 화면 3번째 줄의 Capsule Tab bar
- 랭킹 필터 chip/segment
- 화면 내 별도 섹션 타이틀
### 8.3 시각 규칙
- 선택된 홈 상단 탭 `랭킹`은 흰색 bold text로 표시한다.
- 비선택 탭 `추천`, `팔로잉`은 gray text로 표시한다.
- 카드 corner radius, 이미지 crop, dim gradient, badge 위치는 Figma 기준을 따른다.
- 카드/row의 루트 레이아웃에는 고정 숫자 width/height를 사용하지 않는다.
- 카드 비율과 row 높이는 `aspectRatio`, grid column width, container-relative size 등 반응형 제약을 우선 사용한다.
- 신규 진입 표시는 텍스트 `New` 대신 `ic_rank_new` 아이콘을 사용한다.
- rankChange 상승 badge는 Figma `node-id=24-5674` 기준으로 어두운 배경 위 숫자와 `ic_rank_caret_increase` 아이콘을 사용한다.
- rankChange 하락 badge는 Figma `node-id=24-5675` 기준으로 어두운 배경 위 숫자와 `ic_rank_caret_decrease` 아이콘을 사용한다.
- `rankChange == 0`은 Figma `node-id=24-5678` 예시가 있더라도 숫자 `0`을 표시하지 않고 `ic_rank_caret_stay` 아이콘으로 변동 없음 상태를 표시한다.
- nickname이 길면 카드/row 영역을 넘지 않도록 한 줄 말줄임 또는 기존 프로젝트의 nickname 표시 관례를 따른다.
### 8.4 순위 변화 아이콘
- 상승: `ic_rank_caret_increase`
- 하락: `ic_rank_caret_decrease`
- 변동 없음: `ic_rank_caret_stay`
- 신규 진입: `ic_rank_new`
## 9. Technical Constraints
- 신규 랭킹 탭 관련 파일은 `SodaLive/Sources/V2/Main/Home/Ranking/**` 아래에 둔다.
- `MainHomeView`에는 API 호출, ranking item 변환, 랭킹 세부 UI를 넣지 않는다.
- 기존 `MainHomeRecommendationView`와 추천 탭 API 동작을 변경하지 않는다.
- 기존 `TextTabBar` 또는 홈 상단 탭 컴포넌트를 재사용한다.
- profile image 로딩은 기존 프로젝트의 이미지 로딩 패턴을 따른다.
- 카드/row UI 컴포넌트의 루트 container는 고정 숫자 `frame(width:)`, `frame(height:)`로 제한하지 않는다.
- 외부 라이브러리를 추가하지 않는다.
- Figma에서 제공되는 localhost asset URL을 앱 코드에 직접 사용하지 않는다.
- 신규 순위 변화 아이콘은 `ic_rank_caret_decrease`, `ic_rank_caret_increase`, `ic_rank_caret_stay`, `ic_rank_new` asset을 사용한다.
- 다국어가 필요한 신규 문구는 `I18n`에 추가한다. 신규 진입 표시는 텍스트가 아니라 `ic_rank_new` 아이콘으로 처리한다.
## 10. Success Criteria
- `랭킹` 탭 진입 시 `GET /api/v2/home/rankings/creators` 호출이 발생한다.
- 응답의 `items`가 rank 규칙에 따라 1위 단독, 2~7위 2열, 8~10위 3열, 11위 이상 row로 표시된다.
- 1위, 2~7위, 8~10위는 하나의 카드 컴포넌트를 재사용하고, 11위 이상은 별도 row 컴포넌트를 사용한다.
- 카드/row 컴포넌트의 루트 레이아웃은 고정 숫자 width/height가 아니라 화면 크기 대응 가능한 제약으로 구성되어 있다.
- 최대 20개 item을 표시 대상으로 처리한다.
- 화면에 표시되는 순위 숫자는 배열 index가 아니라 `items[].rank`와 일치한다.
- `showRankChange == false`이면 모든 item에서 순위 변화 UI가 표시되지 않는다.
- `showRankChange == true && isNew == true`이면 `ic_rank_new` 아이콘이 표시된다.
- `showRankChange == true && rankChange > 0`이면 상승 변화량과 `ic_rank_caret_increase` 아이콘이 표시된다.
- `showRankChange == true && rankChange < 0`이면 하락 변화량과 `ic_rank_caret_decrease` 아이콘이 표시된다.
- `showRankChange == true && rankChange == 0`이면 숫자 `0`은 표시되지 않고 `ic_rank_caret_stay` 아이콘이 표시된다.
- 신규 진입 item의 `rankChange == nil`은 오류로 처리하지 않는다.
- `items`가 빈 배열이거나 API 호출이 실패하면 `순위 집계 중입니다.\n잠시 후 다시 시도해 주세요.` 문구가 표시된다.
- Figma 전체 화면 3번째 줄의 Capsule Tab bar가 화면에 배치되지 않는다.
- 랭킹 페이지 자체는 로그인 guard 없이 노출된다.
- item 탭 시 로그인 guard를 거친 뒤 해당 `creatorId`의 크리에이터 상세 화면으로 이동한다.
- `MainHomeView`는 탭 shell 역할만 유지하고 랭킹 탭 세부 구현은 `Ranking` 하위로 분리된다.
## 11. Open Questions
해당 없음.
## 12. Verification Notes
- `docs/agent-guides/documentation-policy.md`를 확인해 신규 PRD 경로와 필수 섹션 기준을 검증했다.
- `docs/prd/sample-prd.md``docs/20260602_메인_홈_추천_UI_API_연동/prd.md`를 확인해 저장소의 PRD 작성 스타일을 맞췄다.
- `SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingView.swift`가 현재 placeholder임을 확인했다.
- `SodaLive/Sources/V2/Main/Home/MainHomeView.swift``MainHomeRankingView`를 탭 콘텐츠로 조합하고 있음을 확인했다.
- Figma `node-id=24-5654`, `24-5659`, `24-5661`, `24-5667`, `24-5670`의 design context와 screenshot을 확인해 랭킹 화면 및 item 형태를 검증했다.
- 사용자 추가 결정 사항을 반영해 최대 item 개수, empty state 문구, API 실패 표시 방식, 로그인 guard 범위, 신규 진입 표시 정책을 확정 요구사항으로 이동했다.
- Figma `node-id=24-5674`, `24-5675`, `24-5678`의 design context와 screenshot을 확인해 상승/하락/0 변화 badge 기준을 검증했다.
- 순위 변화 아이콘 리소스명 `ic_rank_caret_decrease`, `ic_rank_caret_increase`, `ic_rank_caret_stay`, `ic_rank_new`를 요구사항에 반영했다.
- 1~10위 카드 컴포넌트 재사용, 11위 이상 row 분리, 루트 레이아웃의 고정 숫자 width/height 금지 제약을 반영했다.