# 콘텐츠 랭킹 위젯 컴포넌트 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Figma `20:3715`, `20:3718`, `20:3721`, `20:3724` 기준으로 순위 구간별 콘텐츠 랭킹 위젯 컴포넌트를 추가한다. **Architecture:** 랭킹 항목의 순위/차단 관계/텍스트 표시 상태를 순수 Kotlin contract로 먼저 분리하고, 순위 변동 타입은 크리에이터 랭킹과 콘텐츠 랭킹이 공유하는 공용 contract로 둔다. Android custom view와 RecyclerView adapter가 이 contract를 바인딩한다. 카드 UI는 `Large`, `MediumGrid`, `SmallGrid`, `Horizontal` 4개 variant로 나누며, 실제 크기는 row count와 부모 폭으로 계산한다. **Tech Stack:** Android XML Views, Kotlin custom View, RecyclerView, ViewBinding/resource merge, JUnit4 local unit test. --- ## 작업 목표 - 1위는 `Large` 전용 카드로 구현한다. - 2위~7위는 `MediumGrid` 카드로 구현하고 한 줄 2개로 배치한다. - 8위~10위는 `SmallGrid` 카드로 구현하고 한 줄 3개로 배치한다. - 11위 이후는 `Horizontal` 카드로 구현한다. - 콘텐츠명은 순위 구간별 글자 수 제한과 한 줄 말줄임을 적용한다. - rank delta 상태에 따라 상승/하락/동일/신규 진입 UI를 표시한다. - 차단 관계인 크리에이터의 콘텐츠는 이미지 블러, 정보 비노출 또는 대체문구, 터치 불가 상태로 표시한다. - 이미지 크기는 고정하지 않고 row container 폭과 row count로 계산한다. ## 파일 구조 - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt` - 콘텐츠 랭킹 UI에 필요한 순수 데이터 모델과 차단 관계 상태 계산을 정의한다. - Rename/Move: creator ranking에서 사용하는 change type -> `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingChangeType.kt` - `Increase`, `Decrease`, `Stay`, `New` 순위 변동 타입을 크리에이터/콘텐츠 랭킹 공용 타입으로 정의한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardVariant.kt` - `Large`, `MediumGrid`, `SmallGrid`, `Horizontal` 카드 UI variant를 정의한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacement.kt` - rank 기준 variant와 row count를 함께 결정한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculator.kt` - 부모 폭, horizontal gap, row count 기준으로 item width/height를 계산한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentation.kt` - 공용 `RankingChangeType`별 아이콘과 숫자 표시 여부를 정의한다. `Stay`와 `New`는 숫자를 표시하지 않는다. - Create: `app/src/main/res/layout/view_content_ranking_large_card.xml` - 1위 전용 큰 카드 layout을 정의한다. - Create: `app/src/main/res/layout/view_content_ranking_medium_grid_card.xml` - 2위~7위 2열 카드 layout을 정의한다. - Create: `app/src/main/res/layout/view_content_ranking_small_grid_card.xml` - 8위~10위 3열 카드 layout을 정의한다. - Create: `app/src/main/res/layout/view_content_ranking_horizontal_card.xml` - 11위 이후 가로형 카드 layout을 정의한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt` - 1위 전용 rank, delta, 콘텐츠명, 크리에이터명, access state를 바인딩한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingMediumGridCardView.kt` - 2위~7위 rank, delta, 콘텐츠명, 크리에이터명, access state를 바인딩한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingSmallGridCardView.kt` - 8위~10위 rank, delta, 콘텐츠명, 크리에이터명, access state를 바인딩한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt` - 11위 이후 가로형 카드의 rank, delta, 콘텐츠명, 크리에이터명, access state를 바인딩한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingAdapter.kt` - rank별 viewType과 터치 가능 여부를 처리한다. - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacementTest.kt` - rank별 variant/row count 계약을 검증한다. - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItemTest.kt` - 차단 관계 상태와 표시 텍스트 정책을 검증한다. - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculatorTest.kt` - 부모 폭 기반 크기 계산을 검증한다. - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentationTest.kt` - 상승/하락/동일/신규 진입 표시 계약을 검증한다. - Modify: `app/src/main/res/values/strings.xml` - 접근 불가 대체문구 `접근할 수 없는 정보입니다.`를 추가한다. - Modify: `app/src/main/res/values-en/strings.xml`, `app/src/main/res/values-ja/strings.xml` - 기존 다국어 정책에 맞춰 접근 불가 대체문구를 추가한다. - Add if missing: `app/src/main/res/drawable/ic_rank_caret_increase.xml`, `ic_rank_caret_decrease.xml`, `ic_rank_caret_stay.xml`, `ic_rank_new.xml` - Figma 에셋이 프로젝트에 없으면 디자인 에셋을 추가한다. - Modify: `docs/plan-task/20260520_콘텐츠랭킹위젯컴포넌트.md` - 구현 중 체크박스와 검증 기록을 누적한다. ## 구현 계획 ### Task 1: 기존 리소스 및 유사 UI 확인 **Files:** - Read: `docs/prd/20260520_콘텐츠랭킹위젯컴포넌트_prd.md` - Read: `docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md` - Read: `app/src/main/java/kr/co/vividnext/sodalive/common/image/BlurTransformation.kt` - Read: `app/src/main/res/values/colors.xml` - Read: `app/src/main/res/values/dimens.xml` - Read: `app/src/main/res/values/typography.xml` - [x] **Step 1: 현재 랭킹/블러/에셋 사용처 확인** Run: `rg -n "ContentRanking|CreatorRanking|ic_rank_caret|ic_rank_new|BlurTransformation|rank" app/src/main app/src/test docs` Expected: 기존 랭킹 contract, blur 구현, rank 이미지 리소스 사용처를 확인한다. - [x] **Step 2: Figma 세부 컨텍스트 재확인** Run tools: - `Figma_get_design_context(20:3715)` - `Figma_get_design_context(20:3718)` - `Figma_get_design_context(20:3721)` - `Figma_get_design_context(20:3724)` Expected: typography, color, radius, spacing, icon asset name을 확인한다. 도구가 timeout이면 현재 PRD의 screenshot 기준으로 진행하고 검증 기록에 남긴다. - [x] **Step 3: Figma token을 구현 기준으로 정리** Expected token contract: - 공통 카드 image radius: `radius_14` 또는 `14dp` - 공통 dim gradient: top transparent, bottom black, opacity `50%`, transition start `64.423%` - 공통 `rank-num`: background `gray_900` (`#202020`), radius `4dp`, horizontal padding `4dp`, gap `2dp` - 공통 `rank-num` 숫자: Pretendard Variable Medium, `16sp`, line-height `1.45`, white - 공통 caret icon: `14dp x 14dp` - 순위 숫자: Pattaya Regular, white~`#EEEEEE` gradient, `0px 0px 4px rgba(0,0,0,0.48)` shadow - `Large` title: Pretendard Variable Bold, `22sp`, line-height `1.45`, white - `Large` creator: Pretendard Variable Regular, `12sp`, line-height normal, white - `MediumGrid` title: Pretendard Variable Bold, `22sp`, line-height `1.45`, white - `MediumGrid` creator: Pretendard Variable Regular, `12sp`, line-height normal, white - `SmallGrid` title: Pretendard Variable Bold, `14sp`, line-height normal, white - `SmallGrid` creator: Pretendard Variable Regular, `12sp`, line-height normal, white - `Horizontal` title: Pretendard Variable Bold, `18sp`, line-height `1.45`, white - `Horizontal` creator: Pretendard Variable Regular, `14sp`, line-height `1.45`, white ### Task 2: Rank placement contract TDD **Files:** - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacementTest.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardVariant.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacement.kt` - [x] **Step 1: RED - rank별 variant와 row count 테스트 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget.contentranking import org.junit.Assert.assertEquals import org.junit.Test class ContentRankingPlacementTest { @Test fun `rank 1 uses large variant and one item row`() { val placement = ContentRankingPlacement.fromRank(1) assertEquals(ContentRankingCardVariant.Large, placement.variant) assertEquals(1, placement.itemsPerRow) } @Test fun `rank 2 to 7 uses medium grid variant and two item row`() { (2..7).forEach { rank -> val placement = ContentRankingPlacement.fromRank(rank) assertEquals(ContentRankingCardVariant.MediumGrid, placement.variant) assertEquals(2, placement.itemsPerRow) } } @Test fun `rank 8 to 10 uses small grid variant and three item row`() { (8..10).forEach { rank -> val placement = ContentRankingPlacement.fromRank(rank) assertEquals(ContentRankingCardVariant.SmallGrid, placement.variant) assertEquals(3, placement.itemsPerRow) } } @Test fun `rank 11 or greater uses horizontal variant and one item row`() { listOf(11, 12, 100).forEach { rank -> val placement = ContentRankingPlacement.fromRank(rank) assertEquals(ContentRankingCardVariant.Horizontal, placement.variant) assertEquals(1, placement.itemsPerRow) } } @Test(expected = IllegalArgumentException::class) fun `rank less than 1 is invalid`() { ContentRankingPlacement.fromRank(0) } } ``` - [x] **Step 2: RED 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingPlacementTest"` Expected: `Unresolved reference 'ContentRankingPlacement'`로 실패한다. - [x] **Step 3: GREEN - 최소 placement contract 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget.contentranking enum class ContentRankingCardVariant { Large, MediumGrid, SmallGrid, Horizontal } ``` ```kotlin package kr.co.vividnext.sodalive.v2.widget.contentranking data class ContentRankingPlacement( val variant: ContentRankingCardVariant, val itemsPerRow: Int ) { companion object { fun fromRank(rank: Int): ContentRankingPlacement { require(rank >= 1) { "rank must be greater than or equal to 1." } return when (rank) { 1 -> ContentRankingPlacement(ContentRankingCardVariant.Large, itemsPerRow = 1) in 2..7 -> ContentRankingPlacement(ContentRankingCardVariant.MediumGrid, itemsPerRow = 2) in 8..10 -> ContentRankingPlacement(ContentRankingCardVariant.SmallGrid, itemsPerRow = 3) else -> ContentRankingPlacement(ContentRankingCardVariant.Horizontal, itemsPerRow = 1) } } } } ``` - [x] **Step 4: GREEN 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingPlacementTest"` Expected: `BUILD SUCCESSFUL` ### Task 3: Ranking item state contract TDD **Files:** - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItemTest.kt` - Rename/Move: creator ranking에서 사용하는 change type -> `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingChangeType.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt` - [x] **Step 1: RED - 접근 가능/불가 및 텍스트 표시 정책 테스트 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget.contentranking import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class ContentRankingItemTest { @Test fun `blocked item is inaccessible`() { val item = sampleItem(isBlocked = true) assertTrue(item.isInaccessible) assertFalse(item.isTouchable) } @Test fun `accessible item is touchable`() { val item = sampleItem() assertFalse(item.isInaccessible) assertTrue(item.isTouchable) } @Test fun `top ten blocked item hides content and creator names`() { val item = sampleItem(rank = 10, isBlocked = true) assertEquals("", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")) assertEquals("", item.displayCreatorName()) } @Test fun `rank 11 blocked item shows inaccessible message as single line`() { val item = sampleItem(rank = 11, isBlocked = true) assertEquals("접근할 수 없는 정보입니다.", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")) assertEquals("", item.displayCreatorName()) } @Test fun `accessible item shows original names`() { val item = sampleItem(contentName = "콘텐츠 이름", creatorName = "크리에이터 이름") assertEquals("콘텐츠 이름", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")) assertEquals("크리에이터 이름", item.displayCreatorName()) } @Test fun `content title max length follows rank range`() { assertEquals(16, sampleItem(rank = 1).contentNameMaxLength) assertEquals(8, sampleItem(rank = 2).contentNameMaxLength) assertEquals(8, sampleItem(rank = 10).contentNameMaxLength) assertEquals(12, sampleItem(rank = 11).contentNameMaxLength) } private fun sampleItem( rank: Int = 1, contentName: String = "콘텐츠 이름", creatorName: String = "크리에이터 이름", isBlocked: Boolean = false ) = ContentRankingItem( contentId = "content-1", creatorId = "creator-1", rank = rank, previousRank = 5, rankChangeType = RankingChangeType.Increase, rankChangeAmount = 4, contentName = contentName, creatorName = creatorName, imageUrl = "https://example.com/image.png", isBlocked = isBlocked ) } ``` - [x] **Step 2: RED 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItemTest"` Expected: `Unresolved reference 'ContentRankingItem'`로 실패한다. - [x] **Step 3: GREEN - 공용 ranking change type과 최소 item contract 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget.ranking enum class RankingChangeType { Increase, Decrease, Stay, New } ``` ```kotlin package kr.co.vividnext.sodalive.v2.widget.contentranking import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType data class ContentRankingItem( val contentId: String, val creatorId: String, val rank: Int, val previousRank: Int?, val rankChangeType: RankingChangeType, val rankChangeAmount: Int?, val contentName: String, val creatorName: String, val imageUrl: String, val isBlocked: Boolean ) { val isInaccessible: Boolean = isBlocked val isTouchable: Boolean = !isBlocked val contentNameMaxLength: Int = when (rank) { 1 -> 16 in 2..10 -> 8 else -> 12 } fun displayContentName(inaccessibleMessage: String): String = when { !isBlocked -> contentName rank <= 10 -> "" else -> inaccessibleMessage } fun displayCreatorName(): String = if (isBlocked) "" else creatorName } ``` - [x] **Step 4: GREEN 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItemTest"` Expected: `BUILD SUCCESSFUL` ### Task 4: Rank delta presentation contract TDD **Files:** - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentationTest.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentation.kt` - [x] **Step 1: RED - 변동 상태별 표시 테스트 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget.contentranking import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class ContentRankingDeltaPresentationTest { @Test fun `increase shows amount and increase caret`() { val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Increase, amount = 4) assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes) assertEquals("4", presentation.amountText) assertTrue(presentation.showAmount) assertFalse(presentation.replaceWithNewIcon) } @Test fun `decrease shows amount and decrease caret`() { val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Decrease, amount = 2) assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes) assertEquals("2", presentation.amountText) assertTrue(presentation.showAmount) assertFalse(presentation.replaceWithNewIcon) } @Test fun `stay shows only stay icon`() { val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Stay, amount = 0) assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes) assertEquals("", presentation.amountText) assertFalse(presentation.showAmount) assertFalse(presentation.replaceWithNewIcon) } @Test fun `new replaces rank num with new icon`() { val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.New, amount = null) assertEquals(R.drawable.ic_rank_new, presentation.iconRes) assertEquals("", presentation.amountText) assertFalse(presentation.showAmount) assertTrue(presentation.replaceWithNewIcon) } } ``` - [x] **Step 2: RED 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingDeltaPresentationTest"` Expected: `Unresolved reference 'ContentRankingDeltaPresentation'` 또는 missing drawable reference로 실패한다. - [x] **Step 3: GREEN - delta presentation 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget.contentranking import androidx.annotation.DrawableRes import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType data class ContentRankingDeltaPresentation( @DrawableRes val iconRes: Int, val amountText: String, val showAmount: Boolean, val replaceWithNewIcon: Boolean ) { companion object { fun from(type: RankingChangeType, amount: Int?): ContentRankingDeltaPresentation = when (type) { RankingChangeType.Increase -> ContentRankingDeltaPresentation( iconRes = R.drawable.ic_rank_caret_increase, amountText = requireNotNull(amount).toString(), showAmount = true, replaceWithNewIcon = false ) RankingChangeType.Decrease -> ContentRankingDeltaPresentation( iconRes = R.drawable.ic_rank_caret_decrease, amountText = requireNotNull(amount).toString(), showAmount = true, replaceWithNewIcon = false ) RankingChangeType.Stay -> ContentRankingDeltaPresentation( iconRes = R.drawable.ic_rank_caret_stay, amountText = "", showAmount = false, replaceWithNewIcon = false ) RankingChangeType.New -> ContentRankingDeltaPresentation( iconRes = R.drawable.ic_rank_new, amountText = "", showAmount = false, replaceWithNewIcon = true ) } } } ``` - [x] **Step 4: GREEN 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingDeltaPresentationTest"` Expected: `BUILD SUCCESSFUL` ### Task 5: Layout calculator contract TDD **Files:** - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculatorTest.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculator.kt` - [x] **Step 1: RED - 부모 폭 기반 크기 계산 테스트 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget.contentranking import org.junit.Assert.assertEquals import org.junit.Test class ContentRankingLayoutCalculatorTest { @Test fun `square item width divides available width by items per row`() { val size = ContentRankingLayoutCalculator.calculateSquareItemSize( parentWidthPx = 374, horizontalGapPx = 4, itemsPerRow = 2 ) assertEquals(185, size.widthPx) assertEquals(185, size.heightPx) } @Test fun `three column square item subtracts two gaps`() { val size = ContentRankingLayoutCalculator.calculateSquareItemSize( parentWidthPx = 374, horizontalGapPx = 4, itemsPerRow = 3 ) assertEquals(122, size.widthPx) assertEquals(122, size.heightPx) } @Test fun `horizontal item keeps figma ratio`() { val size = ContentRankingLayoutCalculator.calculateHorizontalItemSize(parentWidthPx = 374) assertEquals(374, size.widthPx) assertEquals(100, size.heightPx) } } ``` - [x] **Step 2: RED 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingLayoutCalculatorTest"` Expected: `Unresolved reference 'ContentRankingLayoutCalculator'`로 실패한다. - [x] **Step 3: GREEN - 최소 layout calculator 추가** ```kotlin package kr.co.vividnext.sodalive.v2.widget.contentranking data class ContentRankingItemSize( val widthPx: Int, val heightPx: Int ) object ContentRankingLayoutCalculator { fun calculateSquareItemSize(parentWidthPx: Int, horizontalGapPx: Int, itemsPerRow: Int): ContentRankingItemSize { require(parentWidthPx > 0) { "parentWidthPx must be greater than 0." } require(horizontalGapPx >= 0) { "horizontalGapPx must be greater than or equal to 0." } require(itemsPerRow > 0) { "itemsPerRow must be greater than 0." } val totalGap = horizontalGapPx * (itemsPerRow - 1) val size = (parentWidthPx - totalGap) / itemsPerRow return ContentRankingItemSize(widthPx = size, heightPx = size) } fun calculateHorizontalItemSize(parentWidthPx: Int): ContentRankingItemSize { require(parentWidthPx > 0) { "parentWidthPx must be greater than 0." } val height = (parentWidthPx * 100f / 374f).toInt() return ContentRankingItemSize(widthPx = parentWidthPx, heightPx = height) } } ``` - [x] **Step 4: GREEN 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingLayoutCalculatorTest"` Expected: `BUILD SUCCESSFUL` ### Task 6: XML 카드 레이아웃과 custom view 추가 **Files:** - Create: `app/src/main/res/layout/view_content_ranking_large_card.xml` - Create: `app/src/main/res/layout/view_content_ranking_medium_grid_card.xml` - Create: `app/src/main/res/layout/view_content_ranking_small_grid_card.xml` - Create: `app/src/main/res/layout/view_content_ranking_horizontal_card.xml` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingMediumGridCardView.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingSmallGridCardView.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt` - Modify: `app/src/main/res/values/strings.xml` - Modify: `app/src/main/res/values-en/strings.xml` - Modify: `app/src/main/res/values-ja/strings.xml` - [x] **Step 1: 접근 불가 문자열 추가** Expected: - `strings.xml`: `접근할 수 없는 정보입니다.` - `values-en/strings.xml`: 동일 의미의 영문 문자열을 추가한다. - `values-ja/strings.xml`: 동일 의미의 일본어 문자열을 추가한다. - [x] **Step 2: 4개 XML layout 추가** Expected: - 모든 title/creator `TextView`는 `android:maxLines="1"`, `android:ellipsize="end"`를 가진다. - 1위~10위 layout은 이름 영역을 `gone`으로 전환해도 dim gradient view가 남도록 overlay와 label container를 분리한다. - 11위 이후 layout은 접근 불가 상태에서 단일 `TextView`만 표시할 수 있도록 title/creator 영역과 inaccessible message 영역을 분리한다. - 이미지 view는 `centerCrop`, radius `14dp`, blur 적용 가능 구조를 가진다. - [x] **Step 3: 4개 custom view 추가** Expected: - 각 custom view는 `bind(item: ContentRankingItem)` API를 제공한다. - 각 custom view는 `ContentRankingDeltaPresentation`을 사용해 `rank-num` 또는 `ic_rank_new`를 표시한다. - 각 custom view는 `item.isBlocked`일 때 이미지 blur와 터치 불가 상태를 적용한다. - 1위~10위 custom view는 차단 상태에서 콘텐츠명/크리에이터명 영역만 숨기고 gradient는 유지한다. - 11위 이후 custom view는 차단 상태에서 `content_ranking_inaccessible_info`만 한 줄로 표시한다. - [x] **Step 4: resource merge 확인** Run: `./gradlew :app:assembleDebug` Expected: `BUILD SUCCESSFUL` ### Task 7: Adapter 추가 및 통합 계약 검증 **Files:** - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingAdapter.kt` - Modify: `docs/plan-task/20260520_콘텐츠랭킹위젯컴포넌트.md` - [x] **Step 1: Adapter 추가** Expected: - `getItemViewType`은 `ContentRankingPlacement.fromRank(item.rank).variant`를 기준으로 view type을 반환한다. - `onCreateViewHolder`는 4개 custom view 중 하나를 생성한다. - `onBindViewHolder`는 item을 bind하고, `item.isTouchable`이 false이면 click listener를 제거한다. - 외부 클릭 동작은 `onItemClick: (ContentRankingItem) -> Unit` 형태로 호출부에 위임한다. - [x] **Step 2: 단위 테스트 전체 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"` Expected: `BUILD SUCCESSFUL` - [x] **Step 3: 관련 빌드 실행** Run: `./gradlew :app:assembleDebug` Expected: `BUILD SUCCESSFUL` - [x] **Step 4: 계획 문서 검증 기록 누적** Expected: - 실행한 명령, 결과, 실패 시 원인과 후속 조치를 이 문서 하단 `검증 기록`에 누적한다. --- ## 검증 기록 - 2026-05-20: 문서만 먼저 작성하는 요청이므로 구현/빌드/테스트는 실행하지 않았다. Figma `20:3715`, `20:3718`, `20:3721`, `20:3724`의 design context와 screenshot을 확인해 PRD 및 구현 계획에 반영했다. - 2026-05-20: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingItemTest" --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingDeltaPresentationTest" --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`를 먼저 실행해 `RankingChangeType` 및 콘텐츠 랭킹 contract 미구현으로 실패하는 RED를 확인했다. - 2026-05-20: 공용 `RankingChangeType`, 콘텐츠 랭킹 placement/item/delta/layout calculator, XML/custom view/adapter를 구현한 뒤 동일 단위 테스트 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. - 2026-05-20: `./gradlew :app:assembleDebug`를 실행해 Android resource merge, Kotlin compile, debug APK assemble이 `BUILD SUCCESSFUL`임을 확인했다. Kotlin LSP는 현재 환경에 서버가 없어 `lsp_diagnostics`를 실행할 수 없었다.