Files
sodalive-android/docs/plan-task/20260520_콘텐츠랭킹위젯컴포넌트.md

28 KiB

콘텐츠 랭킹 위젯 컴포넌트 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별 아이콘과 숫자 표시 여부를 정의한다. StayNew는 숫자를 표시하지 않는다.
  • 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

  • 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 이미지 리소스 사용처를 확인한다.

  • 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 기준으로 진행하고 검증 기록에 남긴다.

  • 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

  • Step 1: RED - rank별 variant와 row count 테스트 추가

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)
    }
}
  • Step 2: RED 실행

Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingPlacementTest"

Expected: Unresolved reference 'ContentRankingPlacement'로 실패한다.

  • Step 3: GREEN - 최소 placement contract 추가
package kr.co.vividnext.sodalive.v2.widget.contentranking

enum class ContentRankingCardVariant {
    Large,
    MediumGrid,
    SmallGrid,
    Horizontal
}
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)
            }
        }
    }
}
  • 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

  • Step 1: RED - 접근 가능/불가 및 텍스트 표시 정책 테스트 추가

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
    )
}
  • Step 2: RED 실행

Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItemTest"

Expected: Unresolved reference 'ContentRankingItem'로 실패한다.

  • Step 3: GREEN - 공용 ranking change type과 최소 item contract 추가
package kr.co.vividnext.sodalive.v2.widget.ranking

enum class RankingChangeType {
    Increase,
    Decrease,
    Stay,
    New
}
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
}
  • 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

  • Step 1: RED - 변동 상태별 표시 테스트 추가

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)
    }
}
  • Step 2: RED 실행

Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingDeltaPresentationTest"

Expected: Unresolved reference 'ContentRankingDeltaPresentation' 또는 missing drawable reference로 실패한다.

  • Step 3: GREEN - delta presentation 추가
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
            )
        }
    }
}
  • 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

  • Step 1: RED - 부모 폭 기반 크기 계산 테스트 추가

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)
    }
}
  • Step 2: RED 실행

Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingLayoutCalculatorTest"

Expected: Unresolved reference 'ContentRankingLayoutCalculator'로 실패한다.

  • Step 3: GREEN - 최소 layout calculator 추가
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)
    }
}
  • 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

  • Step 1: 접근 불가 문자열 추가

Expected:

  • strings.xml: <string name="content_ranking_inaccessible_info">접근할 수 없는 정보입니다.</string>

  • values-en/strings.xml: 동일 의미의 영문 문자열을 추가한다.

  • values-ja/strings.xml: 동일 의미의 일본어 문자열을 추가한다.

  • Step 2: 4개 XML layout 추가

Expected:

  • 모든 title/creator TextViewandroid: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 적용 가능 구조를 가진다.

  • 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만 한 줄로 표시한다.

  • 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

  • Step 1: Adapter 추가

Expected:

  • getItemViewTypeContentRankingPlacement.fromRank(item.rank).variant를 기준으로 view type을 반환한다.

  • onCreateViewHolder는 4개 custom view 중 하나를 생성한다.

  • onBindViewHolder는 item을 bind하고, item.isTouchable이 false이면 click listener를 제거한다.

  • 외부 클릭 동작은 onItemClick: (ContentRankingItem) -> Unit 형태로 호출부에 위임한다.

  • Step 2: 단위 테스트 전체 실행

Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"

Expected: BUILD SUCCESSFUL

  • Step 3: 관련 빌드 실행

Run: ./gradlew :app:assembleDebug

Expected: BUILD SUCCESSFUL

  • 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를 실행할 수 없었다.