diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index f5de68f4..3e2f1f65 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -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 = ""; }; 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingView.swift; sourceTree = ""; }; 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingView.swift; sourceTree = ""; }; + 401B3F0A2FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeCreatorRankingResponse.swift; sourceTree = ""; }; + 401B3F0B2FEEF10000A5BBA3 /* MainHomeRankingApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingApi.swift; sourceTree = ""; }; + 401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingRepository.swift; sourceTree = ""; }; + 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingViewModel.swift; sourceTree = ""; }; 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeLiveSection.swift; sourceTree = ""; }; 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeBannerSection.swift; sourceTree = ""; }; 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeActiveCreatorSection.swift; sourceTree = ""; }; @@ -2742,11 +2755,31 @@ 401A3FB82FEEAFA000A5BBA3 /* Ranking */ = { isa = PBXGroup; children = ( + 401B3F0E2FEEF10000A5BBA3 /* Models */, + 401B3F0F2FEEF10000A5BBA3 /* Repository */, 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */, + 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */, ); path = Ranking; sourceTree = ""; }; + 401B3F0E2FEEF10000A5BBA3 /* Models */ = { + isa = PBXGroup; + children = ( + 401B3F0A2FEEF10000A5BBA3 /* MainHomeCreatorRankingResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + 401B3F0F2FEEF10000A5BBA3 /* Repository */ = { + isa = PBXGroup; + children = ( + 401B3F0B2FEEF10000A5BBA3 /* MainHomeRankingApi.swift */, + 401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; 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 */, diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_decrease.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_decrease.imageset/Contents.json new file mode 100644 index 00000000..cb93f892 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_decrease.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_decrease.imageset/ic_rank_caret_decrease.png b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_decrease.imageset/ic_rank_caret_decrease.png new file mode 100644 index 00000000..b17a4a54 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_decrease.imageset/ic_rank_caret_decrease.png differ diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_increase.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_increase.imageset/Contents.json new file mode 100644 index 00000000..c85aee3f --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_increase.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_increase.imageset/ic_rank_caret_increase.png b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_increase.imageset/ic_rank_caret_increase.png new file mode 100644 index 00000000..6b1bb334 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_increase.imageset/ic_rank_caret_increase.png differ diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_stay.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_stay.imageset/Contents.json new file mode 100644 index 00000000..0f4c2b73 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_stay.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_stay.imageset/ic_rank_caret_stay.png b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_stay.imageset/ic_rank_caret_stay.png new file mode 100644 index 00000000..f003eae5 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_caret_stay.imageset/ic_rank_caret_stay.png differ diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_rank_new.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_new.imageset/Contents.json new file mode 100644 index 00000000..d0849011 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_new.imageset/Contents.json @@ -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 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/v2/ic_rank_new.imageset/ic_rank_new.png b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_new.imageset/ic_rank_new.png new file mode 100644 index 00000000..6d2887ab Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/v2/ic_rank_new.imageset/ic_rank_new.png differ diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 118421bf..fa31440a 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -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: "ライブ") diff --git a/SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingViewModel.swift b/SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingViewModel.swift new file mode 100644 index 00000000..d07e8d33 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Ranking/MainHomeRankingViewModel.swift @@ -0,0 +1,63 @@ +import Foundation +import Combine + +final class MainHomeRankingViewModel: ObservableObject { + private let repository = MainHomeRankingRepository() + private var subscription = Set() + + @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.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) + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Ranking/Models/MainHomeCreatorRankingResponse.swift b/SodaLive/Sources/V2/Main/Home/Ranking/Models/MainHomeCreatorRankingResponse.swift new file mode 100644 index 00000000..fa7b1d21 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Ranking/Models/MainHomeCreatorRankingResponse.swift @@ -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 } +} diff --git a/SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingApi.swift b/SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingApi.swift new file mode 100644 index 00000000..3946fa52 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingApi.swift @@ -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))"] + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingRepository.swift b/SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingRepository.swift new file mode 100644 index 00000000..0ad7faf7 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingRepository.swift @@ -0,0 +1,13 @@ +import Foundation +import Combine + +import CombineMoya +import Moya + +final class MainHomeRankingRepository { + private let api = MoyaProvider() + + func getCreatorRankings() -> AnyPublisher { + return api.requestPublisher(.getCreatorRankings) + } +} diff --git a/docs/20260630_메인_홈_랭킹_탭/plan-task.md b/docs/20260630_메인_홈_랭킹_탭/plan-task.md new file mode 100644 index 00000000..ceab25db --- /dev/null +++ b/docs/20260630_메인_홈_랭킹_탭/plan-task.md @@ -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`를 사용한다. + - `getCreatorRankings() -> AnyPublisher` 메서드를 제공한다. + - 검증 기준: + - 실행 명령: `rg "final class MainHomeRankingRepository|MoyaProvider|getCreatorRankings\\(\\)|AnyPublisher" 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`를 디코딩한다. + - 성공 시 `items`는 서버 순서를 유지하되 최대 20개만 보관한다. + - 빈 배열 또는 API 실패/디코딩 실패 시 `emptyMessage`에 `I18n.HomeRanking.emptyStateMessage`를 설정한다. + - 실패 시 toast 대신 empty state를 표시한다. + - 검증 기준: + - 실행 명령: `rg "final class MainHomeRankingViewModel|fetchRankings|ApiResponse|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` 추가 + - 검증 메모: + - 현재 저장소 가이드상 테스트 번들 타깃이 확인되지 않아 신규 단위 테스트는 추가하지 않고 정적 검색 및 빌드 검증으로 확인 diff --git a/docs/20260630_메인_홈_랭킹_탭/prd.md b/docs/20260630_메인_홈_랭킹_탭/prd.md new file mode 100644 index 00000000..6577d9ea --- /dev/null +++ b/docs/20260630_메인_홈_랭킹_탭/prd.md @@ -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` 디코딩을 우선한다. + +#### 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 금지 제약을 반영했다.