Files
sodalive-android/docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md

37 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:3702, 20:3709, 20:3711, 20:3713 기준으로 순위 구간별 크리에이터 랭킹 위젯 컴포넌트를 추가한다.

Architecture: 랭킹 항목의 순위/변동/차단 관계 상태를 순수 Kotlin contract로 먼저 분리하고, Android custom view와 RecyclerView adapter가 이 contract를 바인딩한다. 카드 UI는 Large, Compact, Horizontal 3개 variant로 나누며, Compact는 2위~10위가 공유하고 실제 크기는 row count와 부모 폭으로 계산한다.

Tech Stack: Android XML Views, Kotlin custom View, RecyclerView, ViewBinding/resource merge, JUnit4 local unit test.


작업 목표

  • 1위는 Large 전용 카드로 구현한다.
  • 2위10위는 동일한 Compact 카드 UI로 구현하고, 2위7위는 한 줄 2개, 8위~10위는 한 줄 3개로 배치한다.
  • 11위 이후는 Horizontal 카드로 구현한다.
  • rank delta 상태에 따라 상승/하락/동일/신규 진입 UI를 표시한다.
  • 차단 관계인 크리에이터는 이미지 블러, 이름 비노출 또는 대체문구, 터치 불가 상태로 표시한다.
  • 이미지 크기는 고정하지 않고 row container 폭과 row count로 계산한다.

파일 구조

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt
    • 랭킹 UI에 필요한 순수 데이터 모델과 차단 관계 상태 계산을 정의한다.
  • Rename/Move: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.kt -> 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/creatorranking/CreatorRankingCardVariant.kt
    • Large, Compact, Horizontal 카드 UI variant를 정의한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacement.kt
    • rank 기준 variant와 row count를 함께 결정한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculator.kt
    • 부모 폭, horizontal gap, row count 기준으로 item width/height를 계산한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt
    • 공용 RankingChangeType별 아이콘과 숫자 표시 여부를 정의한다. StayNew는 숫자를 표시하지 않는다.
  • Create: app/src/main/res/layout/view_creator_ranking_large_card.xml
    • 1위 전용 큰 정사각형 카드 layout을 정의한다.
  • Create: app/src/main/res/layout/view_creator_ranking_compact_card.xml
    • 2위~10위 공통 정사각형 카드 layout을 정의한다.
  • Create: app/src/main/res/layout/view_creator_ranking_horizontal_card.xml
    • 11위 이후 가로형 카드 layout을 정의한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLargeCardView.kt
    • 1위 전용 rank, delta, name, access state를 바인딩한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCompactCardView.kt
    • 2위~10위 공통 rank, delta, name, access state를 바인딩한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingHorizontalCardView.kt
    • 11위 이후 가로형 카드의 rank, delta, name, access state를 바인딩한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt
    • rank별 viewType과 터치 가능 여부를 처리한다.
  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacementTest.kt
    • rank별 variant/row count 계약을 검증한다.
  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItemTest.kt
    • 차단 관계 상태와 표시 이름 정책을 검증한다.
  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculatorTest.kt
    • 부모 폭 기반 크기 계산을 검증한다.
  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentationTest.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: app/src/main/java/kr/co/vividnext/sodalive/home/CreatorRankingAdapter.kt

  • Read: app/src/main/res/layout/item_home_creator.xml

  • 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 "CreatorRankingAdapter|ic_rank_caret|ic_rank_new|BlurTransformation|img_rank_" app/src/main app/src/test docs

Expected: 기존 홈 크리에이터 랭킹과 blur 구현, 기존 rank 이미지 리소스 사용처를 확인한다.

  • Step 2: Figma 세부 컨텍스트 재확인

Run tools:

  • Figma_get_design_context(20:3702)
  • Figma_get_design_context(20:3709)
  • Figma_get_design_context(20:3711)
  • Figma_get_design_context(20:3713)

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 creator name: Pretendard Variable Bold, 32sp, line-height 1.45, white
  • 2열 Compact creator name: Pretendard Variable Bold, 22sp, line-height 1.45, white
  • 3열 Compact creator name: Pretendard Variable Bold, 14sp, line-height normal, white
  • Horizontal creator name: Pretendard Variable Bold, 18sp, line-height 1.45, white

Task 2: Rank placement contract TDD

Files:

  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacementTest.kt

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCardVariant.kt

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacement.kt

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

package kr.co.vividnext.sodalive.v2.widget.creatorranking

import org.junit.Assert.assertEquals
import org.junit.Test

class CreatorRankingPlacementTest {

    @Test
    fun `rank 1 uses large variant and one item row`() {
        val placement = CreatorRankingPlacement.fromRank(1)

        assertEquals(CreatorRankingCardVariant.Large, placement.variant)
        assertEquals(1, placement.itemsPerRow)
    }

    @Test
    fun `rank 2 to 7 uses compact variant and two item row`() {
        (2..7).forEach { rank ->
            val placement = CreatorRankingPlacement.fromRank(rank)

            assertEquals(CreatorRankingCardVariant.Compact, placement.variant)
            assertEquals(2, placement.itemsPerRow)
        }
    }

    @Test
    fun `rank 8 to 10 uses compact variant and three item row`() {
        (8..10).forEach { rank ->
            val placement = CreatorRankingPlacement.fromRank(rank)

            assertEquals(CreatorRankingCardVariant.Compact, 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 = CreatorRankingPlacement.fromRank(rank)

            assertEquals(CreatorRankingCardVariant.Horizontal, placement.variant)
            assertEquals(1, placement.itemsPerRow)
        }
    }

    @Test(expected = IllegalArgumentException::class)
    fun `rank less than 1 is invalid`() {
        CreatorRankingPlacement.fromRank(0)
    }
}
  • Step 2: RED 실행

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

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

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

enum class CreatorRankingCardVariant {
    Large,
    Compact,
    Horizontal
}
package kr.co.vividnext.sodalive.v2.widget.creatorranking

data class CreatorRankingPlacement(
    val variant: CreatorRankingCardVariant,
    val itemsPerRow: Int
) {
    companion object {
        fun fromRank(rank: Int): CreatorRankingPlacement {
            require(rank >= 1) { "rank must be greater than or equal to 1." }
            return when (rank) {
                1 -> CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
                in 2..7 -> CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 2)
                in 8..10 -> CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 3)
                else -> CreatorRankingPlacement(CreatorRankingCardVariant.Horizontal, itemsPerRow = 1)
            }
        }
    }
}
  • Step 4: GREEN 실행

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

Expected: BUILD SUCCESSFUL

Task 3: Ranking item state contract TDD

Files:

  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItemTest.kt

  • Rename/Move: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.kt -> app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingChangeType.kt

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt

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

package kr.co.vividnext.sodalive.v2.widget.creatorranking

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 CreatorRankingItemTest {

    @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 creator name`() {
        val item = sampleItem(rank = 10, isBlocked = true)

        assertEquals("", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
    }

    @Test
    fun `rank 11 blocked item shows inaccessible message`() {
        val item = sampleItem(rank = 11, isBlocked = true)

        assertEquals("접근할 수 없는 정보입니다.", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
    }

    @Test
    fun `accessible item shows creator name`() {
        val item = sampleItem(creatorName = "크리에이터 이름")

        assertEquals("크리에이터 이름", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
    }

    private fun sampleItem(
        creatorId: Long = 1L,
        rank: Int = 1,
        previousRank: Int? = 5,
        rankChangeType: RankingChangeType = RankingChangeType.Increase,
        rankChangeAmount: Int = 4,
        creatorName: String = "크리에이터 이름",
        imageUrl: String = "https://example.com/image.png",
        isBlocked: Boolean = false
    ) = CreatorRankingItem(
        creatorId = creatorId,
        rank = rank,
        previousRank = previousRank,
        rankChangeType = rankChangeType,
        rankChangeAmount = rankChangeAmount,
        creatorName = creatorName,
        imageUrl = imageUrl,
        isBlocked = isBlocked
    )
}
  • Step 2: RED 실행

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

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

  • Step 3: GREEN - 순수 상태 모델 추가
package kr.co.vividnext.sodalive.v2.widget.ranking

enum class RankingChangeType {
    Increase,
    Decrease,
    Stay,
    New
}
package kr.co.vividnext.sodalive.v2.widget.creatorranking

import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType

data class CreatorRankingItem(
    val creatorId: Long,
    val rank: Int,
    val previousRank: Int?,
    val rankChangeType: RankingChangeType,
    val rankChangeAmount: Int,
    val creatorName: String,
    val imageUrl: String,
    val isBlocked: Boolean
) {
    init {
        require(rank >= 1) { "rank must be greater than or equal to 1." }
    }

    val isInaccessible: Boolean = isBlocked

    val isTouchable: Boolean = !isBlocked

    fun displayName(inaccessibleMessage: String): String {
        if (!isInaccessible) return creatorName
        return if (rank <= 10) "" else inaccessibleMessage
    }
}
  • Step 4: GREEN 실행

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

Expected: BUILD SUCCESSFUL

Task 4: Rank delta presentation TDD

Files:

  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentationTest.kt

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt

  • Step 1: RED - 순위 변동 표시 정책 테스트 추가

package kr.co.vividnext.sodalive.v2.widget.creatorranking

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.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

class CreatorRankingDeltaPresentationTest {

    @Test
    fun `increase shows caret and amount`() {
        val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Increase, amount = 4)

        assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes)
        assertTrue(presentation.showAmount)
        assertEquals("4", presentation.amountText)
    }

    @Test
    fun `decrease shows caret and amount`() {
        val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Decrease, amount = 4)

        assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes)
        assertTrue(presentation.showAmount)
        assertEquals("4", presentation.amountText)
    }

    @Test
    fun `stay shows stay icon without amount`() {
        val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Stay, amount = 0)

        assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes)
        assertFalse(presentation.showAmount)
        assertNull(presentation.amountText)
    }

    @Test
    fun `new shows new image without amount`() {
        val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.New, amount = 0)

        assertEquals(R.drawable.ic_rank_new, presentation.iconRes)
        assertFalse(presentation.showAmount)
        assertNull(presentation.amountText)
    }
}
  • Step 2: RED 실행

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

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

  • Step 3: GREEN - delta presentation contract 추가
package kr.co.vividnext.sodalive.v2.widget.creatorranking

import androidx.annotation.DrawableRes
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType

data class CreatorRankingDeltaPresentation(
    @DrawableRes val iconRes: Int,
    val showAmount: Boolean,
    val amountText: String?
) {
    companion object {
        fun from(type: RankingChangeType, amount: Int): CreatorRankingDeltaPresentation = when (type) {
            RankingChangeType.Increase -> CreatorRankingDeltaPresentation(
                iconRes = R.drawable.ic_rank_caret_increase,
                showAmount = true,
                amountText = amount.toString()
            )
            RankingChangeType.Decrease -> CreatorRankingDeltaPresentation(
                iconRes = R.drawable.ic_rank_caret_decrease,
                showAmount = true,
                amountText = amount.toString()
            )
            RankingChangeType.Stay -> CreatorRankingDeltaPresentation(
                iconRes = R.drawable.ic_rank_caret_stay,
                showAmount = false,
                amountText = null
            )
            RankingChangeType.New -> CreatorRankingDeltaPresentation(
                iconRes = R.drawable.ic_rank_new,
                showAmount = false,
                amountText = null
            )
        }
    }
}
  • Step 4: GREEN 실행

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

Expected: BUILD SUCCESSFUL

Task 5: 부모 폭 기반 layout 계산 TDD

Files:

  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculatorTest.kt

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculator.kt

  • Step 1: RED - 고정 이미지 크기 방지 계산 테스트 추가

package kr.co.vividnext.sodalive.v2.widget.creatorranking

import org.junit.Assert.assertEquals
import org.junit.Test

class CreatorRankingLayoutCalculatorTest {

    @Test
    fun `large card fills available width as square`() {
        val size = CreatorRankingLayoutCalculator.calculate(
            parentWidthPx = 374,
            horizontalGapPx = 4,
            placement = CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
        )

        assertEquals(374, size.widthPx)
        assertEquals(374, size.heightPx)
    }

    @Test
    fun `compact card can use two columns`() {
        val size = CreatorRankingLayoutCalculator.calculate(
            parentWidthPx = 374,
            horizontalGapPx = 4,
            placement = CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 2)
        )

        assertEquals(185, size.widthPx)
        assertEquals(185, size.heightPx)
    }

    @Test
    fun `compact card can use three columns`() {
        val size = CreatorRankingLayoutCalculator.calculate(
            parentWidthPx = 374,
            horizontalGapPx = 4,
            placement = CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 3)
        )

        assertEquals(122, size.widthPx)
        assertEquals(122, size.heightPx)
    }

    @Test
    fun `horizontal card keeps figma aspect ratio`() {
        val size = CreatorRankingLayoutCalculator.calculate(
            parentWidthPx = 374,
            horizontalGapPx = 4,
            placement = CreatorRankingPlacement(CreatorRankingCardVariant.Horizontal, itemsPerRow = 1)
        )

        assertEquals(374, size.widthPx)
        assertEquals(100, size.heightPx)
    }
}
  • Step 2: RED 실행

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

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

  • Step 3: GREEN - layout calculator 추가
package kr.co.vividnext.sodalive.v2.widget.creatorranking

object CreatorRankingLayoutCalculator {
    private const val HORIZONTAL_FIGMA_WIDTH = 374
    private const val HORIZONTAL_FIGMA_HEIGHT = 100

    fun calculate(
        parentWidthPx: Int,
        horizontalGapPx: Int,
        placement: CreatorRankingPlacement
    ): CreatorRankingCardSize {
        require(parentWidthPx > 0) { "parentWidthPx must be > 0." }
        require(horizontalGapPx >= 0) { "horizontalGapPx must be >= 0." }
        require(placement.itemsPerRow > 0) { "itemsPerRow must be > 0." }

        val totalGap = horizontalGapPx * (placement.itemsPerRow - 1)
        val width = (parentWidthPx - totalGap) / placement.itemsPerRow
        val height = when (placement.variant) {
            CreatorRankingCardVariant.Large,
            CreatorRankingCardVariant.Compact -> width
            CreatorRankingCardVariant.Horizontal -> (width * HORIZONTAL_FIGMA_HEIGHT) / HORIZONTAL_FIGMA_WIDTH
        }

        return CreatorRankingCardSize(widthPx = width, heightPx = height)
    }
}

data class CreatorRankingCardSize(
    val widthPx: Int,
    val heightPx: Int
)
  • Step 4: GREEN 실행

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

Expected: BUILD SUCCESSFUL

Task 6: Android 리소스와 custom view 추가

Files:

  • Create: app/src/main/res/layout/view_creator_ranking_large_card.xml

  • Create: app/src/main/res/layout/view_creator_ranking_compact_card.xml

  • Create: app/src/main/res/layout/view_creator_ranking_horizontal_card.xml

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLargeCardView.kt

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCompactCardView.kt

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingHorizontalCardView.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: 접근 불가 문자열 추가

Add to app/src/main/res/values/strings.xml:

<string name="creator_ranking_inaccessible_info">접근할 수 없는 정보입니다.</string>

Add equivalent keys to localized files:

<string name="creator_ranking_inaccessible_info">This information is not accessible.</string>
<string name="creator_ranking_inaccessible_info">アクセスできない情報です。</string>
  • Step 2: 1위 전용 large card layout 추가

view_creator_ranking_large_card.xml은 root custom view, image, dim gradient, rank text, large 전용 rank delta container, name text를 포함한다. image width/height는 custom view에서 계산하므로 XML에서는 match_parent 또는 0dp 초기값을 사용한다. 차단 관계 상태에서 name text가 숨겨져도 dim gradient view는 유지되어야 한다.

  • Step 3: 2위~10위 공통 compact card layout 추가

view_creator_ranking_compact_card.xml은 root custom view, image, dim gradient, rank text, compact 공통 rank delta container, name text를 포함한다. 2열/3열 차이는 layout 파일이 아니라 CreatorRankingLayoutCalculator의 결과로만 처리한다. 차단 관계 상태에서 name text가 숨겨져도 dim gradient view는 유지되어야 한다.

  • Step 4: 11위 이후 horizontal card layout 추가

view_creator_ranking_horizontal_card.xml은 root custom view, left rank text, center image, rank delta container, right name text를 포함한다. root height는 custom view에서 계산한다.

  • Step 5: custom view 3종 구현

Required common API:

  • fun bind(item: CreatorRankingItem)
  • fun setCardSize(size: CreatorRankingCardSize)
  • fun imageView(): ImageView
  • fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?)

Required behavior:

  • CreatorRankingDeltaPresentation을 사용해 rank delta icon과 amount 표시 여부를 결정한다.
  • RankingChangeType.Stay이면 숫자 없이 ic_rank_caret_stay만 표시한다.
  • RankingChangeType.New이면 ic_rank_new를 표시하고 rank delta 숫자는 숨긴다.
  • Increase, Decrease는 change type별 caret icon과 rankChangeAmount를 표시한다.
  • LargeCompact에서 item.displayName(...) 결과가 빈 문자열이면 name TextView를 숨기거나 빈 값으로 둔다.
  • LargeCompact에서 name TextView를 숨겨도 dim gradient view는 숨기지 않는다.
  • Horizontal에서 차단 관계 상태이면 이름 영역에 creator_ranking_inaccessible_info를 표시한다.
  • item.isInaccessible이면 image blur 적용 지점을 제공하고 root click listener를 제거한다.
  • item.isTouchable이면 root click listener를 연결한다.

Task 7: Adapter 추가

Files:

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt

  • Step 1: RecyclerView adapter 추가

Required API:

  • constructor parameter: private val onClickItem: (CreatorRankingItem) -> Unit
  • fun submitItems(items: List<CreatorRankingItem>)
  • override fun getItemViewType(position: Int): Int

Required behavior:

  • CreatorRankingPlacement.fromRank(item.rank)로 viewType과 row count를 결정한다.

  • LargeCreatorRankingLargeCardView를 사용한다.

  • CompactCreatorRankingCompactCardView를 사용한다.

  • HorizontalCreatorRankingHorizontalCardView를 사용한다.

  • item.isTouchable이 false이면 click callback을 호출하지 않는다.

  • 외부에서 전달한 parent width와 row count를 기준으로 CreatorRankingLayoutCalculator를 사용한다. parent width를 아직 알 수 없으면 onBindViewHolder에서 itemView의 measured width 또는 RecyclerView width를 기준으로 계산한다.

  • Step 2: 기존 화면에는 아직 연결하지 않기

이번 task에서는 adapter와 reusable widget 추가까지만 수행한다. 기존 CreatorRankingAdapter 또는 화면 RecyclerView 교체는 사용자가 별도 승인한 뒤 진행한다.

Task 8: 검증 및 문서 기록

Files:

  • Modify: docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md

  • Step 1: 단일 테스트 실행

Run:

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

Expected: BUILD SUCCESSFUL

  • Step 2: LSP 진단 실행

Run lsp_diagnostics on modified Kotlin/XML files.

Expected: 새 오류가 없다. Kotlin/XML LSP가 환경에 없으면 그 사실을 검증 기록에 남긴다.

  • Step 3: 리소스 병합/디버그 빌드 실행

Run:

./gradlew :app:assembleDebug

Expected: BUILD SUCCESSFUL

  • Step 4: ViewBinding 생성 확인

Run:

rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewCreatorRanking(LargeCard|CompactCard|HorizontalCard)Binding"

Expected: 신규 layout의 ViewBinding 생성 파일이 출력된다.

  • Step 5: 검증 기록 누적

문서 하단 검증 기록에 실행한 명령, 결과, 빌드 성공 여부, Figma context timeout 여부를 한국어로 기록한다.

체크리스트

  • AC1: 1위는 Large variant로 한 줄에 1개 표시한다.
  • AC2: 2위~7위는 Compact variant로 한 줄에 2개 표시한다.
  • AC3: 8위~10위는 Compact variant로 한 줄에 3개 표시한다.
  • AC4: 11위 이후는 Horizontal variant로 한 줄에 1개 표시한다.
  • AC5: 이미지 크기는 고정 dp가 아니라 부모 폭과 row count로 계산한다.
  • AC6: 상승/하락/동일/신규 진입 rank delta UI가 각각 ic_rank_caret_increase, ic_rank_caret_decrease, ic_rank_caret_stay, ic_rank_new로 표시된다.
  • AC6-1: 동일 순위 상태에서는 숫자 없이 ic_rank_caret_stay만 표시된다.
  • AC7: 차단 관계 상태에서는 이미지가 블러 처리된다.
  • AC8: 차단 관계 상태의 1위~10위 카드에는 크리에이터 이름이 표시되지 않는다.
  • AC8-1: 차단 관계 상태의 1위~10위 카드에서 이름을 숨겨도 dim gradient 영역은 유지된다.
  • AC9: 차단 관계 상태의 11위 이후 카드에는 접근할 수 없는 정보입니다.가 표시된다.
  • AC10: 차단 관계 상태의 카드는 터치할 수 없다.
  • AC11: 접근 가능 상태의 카드는 터치 가능하고 click callback을 호출한다.
  • AC12: 기존 화면 파일은 사용자 추가 승인 없이 교체하지 않는다.

검증 기록

  • 2026-05-20
    • 무엇/왜/어떻게: 사용자 요청에 따라 구현 전 PRD와 구현 계획/TASK 문서만 작성했다. Figma 4개 노드는 크리에이터 랭킹 위젯의 순위 구간별 variant로 정리했다.
    • 실행 명령/도구:
      • Figma_get_design_context(20:3702)
      • Figma_get_design_context(20:3709)
      • Figma_get_design_context(20:3711)
      • Figma_get_design_context(20:3713)
      • Figma_get_metadata(20:3702)
      • Figma_get_metadata(20:3709)
      • Figma_get_metadata(20:3711)
      • Figma_get_metadata(20:3713)
      • Figma_get_screenshot(20:3702)
      • Figma_get_screenshot(20:3709)
      • Figma_get_screenshot(20:3711)
      • Figma_get_screenshot(20:3713)
      • read(docs/agent-guides/workflow-docs-commits.md)
      • read(docs/prd/sample-prd.md)
      • read(docs/prd/20260519_오디오콘텐츠카드컴포넌트_prd.md)
      • read(docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md)
      • rg -n "ic_rank_caret|rank_new|rank|BlurTransformation|blur" app/src/main app/src/test docs
      • read(app/src/main/java/kr/co/vividnext/sodalive/home/CreatorRankingAdapter.kt)
      • read(app/src/main/res/layout/item_home_creator.xml)
      • read(app/src/main/res/layout/fragment_audio_content_main_tab_home.xml)
      • read(app/src/main/java/kr/co/vividnext/sodalive/common/image/BlurTransformation.kt)
      • rg -n "data class GetExplorerSectionCreatorResponse|class GetExplorerSectionCreatorResponse|blocked|block" app/src/main/java/kr/co/vividnext/sodalive/explorer app/src/main/java/kr/co/vividnext/sodalive/home app/src/main/java/kr/co/vividnext/sodalive/audio_content
      • read(app/src/main/java/kr/co/vividnext/sodalive/explorer/GetExplorerResponse.kt)
    • 결과:
      • PRD 문서는 docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md에 작성했다.
      • 계획/TASK 문서는 docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md에 작성했다.
      • Figma_get_design_context는 4개 노드 모두 timeout이었다. 대신 metadata와 screenshot으로 노드 형태를 확인했다.
      • metadata 크기는 참고용으로만 확인했고 구현 크기는 고정하지 않는다.
      • 현재 GetExplorerSectionCreatorResponse에는 이전 순위/변동 상태/차단 관계 여부 필드가 없어 구현 전 데이터 계약 확인이 필요하다.
      • 코드, 리소스, 레이아웃 구현 파일은 변경하지 않았다.
      • 실제 구현과 빌드 검증은 사용자 승인 후 계획 문서 체크리스트에 따라 진행한다.
  • 2026-05-20
    • 무엇/왜/어떻게: 사용자 피드백에 따라 문서의 데이터 계약과 카드 variant 정책을 수정했다. 차단 방향 구분을 제거해 isBlocked 단일 필드로 줄이고, Figma metadata size 고정값을 구현 크기 기준에서 제거했다. 2위~10위는 Compact 단일 UI로 통합하고, 1위 Large는 순위 표시가 달라 별도 카드로 유지했다.
    • 실행 명령/도구:
      • read(docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md)
      • read(docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md)
    • 결과:
      • Data Contract RequirementsisBlockedByMe, hasBlockedMeisBlocked로 축소했다.
      • Metadata size 고정 크기 표기를 제거하고, 실제 사용 영역 폭과 row count 기반 크기 계산으로 변경했다.
      • Medium, Small variant를 제거하고 2위~10위 공통 Compact variant로 통일했다.
      • 1위 전용 Large 카드와 11위 이후 Horizontal 카드는 별도 variant로 유지했다.
  • 2026-05-20
    • 무엇/왜/어떻게: 사용자 확정 사항과 Figma 재확인 결과를 문서에 반영했다. 순위 동일 상태는 숫자 없이 stay 아이콘만 표시하고, 차단 관계 상태에서 1위~10위 이름을 숨겨도 dim gradient는 유지한다. Figma_get_design_context 재시도에 성공해 typography/color/radius 토큰을 PRD와 계획 문서에 반영했다.
    • 실행 명령/도구:
      • Figma_get_design_context(20:3702)
      • Figma_get_design_context(20:3709)
      • Figma_get_design_context(20:3711)
      • Figma_get_design_context(20:3713)
      • Figma_get_screenshot(20:3702)
      • Figma_get_screenshot(20:3709)
      • Figma_get_screenshot(20:3711)
      • Figma_get_screenshot(20:3713)
    • 결과:
      • rankChangeType == Stayic_rank_caret_stay 아이콘만 표시하고 숫자를 숨기도록 확정했다.
      • 차단 관계 상태에서 1위~10위 카드의 creator name은 숨기되 dim gradient overlay는 유지하도록 확정했다.
      • 공통 radius 14dp, rank-num 배경 gray_900 #202020, rank-num radius 4dp, gap 2dp, caret 14dp, dim gradient opacity 50%, Pretendard/Pattaya typography 기준을 문서화했다.
  • 2026-05-20
    • 무엇/왜/어떻게: 계획 문서 기준으로 creator ranking reusable widget 구현과 검증을 수행했다. 순수 contract는 TDD로 RED 실패를 확인한 뒤 GREEN 구현했고, Android layout/custom view/adapter는 기존 화면에 연결하지 않는 범위로 추가했다.
    • 실행 명령/도구:
      • ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"
      • lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt)
      • ./gradlew :app:assembleDebug
      • rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewCreatorRanking(LargeCard|CompactCard|HorizontalCard)Binding"
    • 결과:
      • creator ranking 단위 테스트는 BUILD SUCCESSFUL로 통과했다.
      • Kotlin LSP는 현재 환경에 .kt 확장자 서버가 없어 No LSP server configured for extension: .kt로 진단을 수행할 수 없었다.
      • :app:assembleDebugBUILD SUCCESSFUL로 통과했다.
      • ViewCreatorRankingLargeCardBinding, ViewCreatorRankingCompactCardBinding, ViewCreatorRankingHorizontalCardBinding 생성 파일을 확인했다.
      • 기존 화면 교체 없이 kr.co.vividnext.sodalive.v2.widget.creatorranking 하위 reusable widget만 추가했다.
  • 2026-05-20
    • 무엇/왜/어떻게: 구현 후 코드 리뷰에서 지적된 row 배치 보조 API, API 23~30 blur fallback, New rank delta 표시 크기/배경 문제를 보강했다.
    • 실행 명령/도구:
      • requesting-code-review 기반 읽기 전용 리뷰
      • ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"
      • lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking)
      • ./gradlew :app:assembleDebug
      • rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewCreatorRanking(LargeCard|CompactCard|HorizontalCard)Binding"
    • 결과:
      • CreatorRankingAdapter.GRID_SPAN_COUNT, createSpanSizeLookup(), createGridLayoutManager(context)를 추가해 1위/11위 이후 full span, 2위7위 2열, 8위10위 3열 구성을 호출부가 적용할 수 있게 했다.
      • API 31 미만 차단 이미지에는 기존 BlurTransformation 기반 Coil blur fallback을 적용하고, API 31 이상 RenderEffect 경로는 유지했다.
      • RankingChangeType.New는 pill 배경 없이 36dp x 23dp 아이콘으로 표시하고, caret 계열은 기존 14dp x 14dp pill 표시를 유지했다.
      • creator ranking 단위 테스트와 :app:assembleDebug는 모두 BUILD SUCCESSFUL로 통과했다.
      • Kotlin LSP는 현재 환경에 .kt 확장자 서버가 없어 No LSP server configured for extension: .kt로 진단을 수행할 수 없었다.
      • 보강 후 재리뷰에서 기존 Important 3건은 모두 해소됐고 새 Critical/Important 이슈는 없음을 확인했다.