feat(widget): 크리에이터 랭킹 위젯을 추가한다

This commit is contained in:
2026-05-20 10:41:07 +09:00
parent 6fda122091
commit 01fea58e4c
35 changed files with 2341 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kotlin.math.roundToInt
class CreatorRankingAdapter(
private val onClickItem: (CreatorRankingItem) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val items = mutableListOf<CreatorRankingItem>()
override fun getItemViewType(position: Int): Int = when (CreatorRankingPlacement.fromRank(items[position].rank).variant) {
CreatorRankingCardVariant.Large -> VIEW_TYPE_LARGE
CreatorRankingCardVariant.Compact -> VIEW_TYPE_COMPACT
CreatorRankingCardVariant.Horizontal -> VIEW_TYPE_HORIZONTAL
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_LARGE -> LargeViewHolder(
inflater.inflate(R.layout.view_creator_ranking_large_card, parent, false) as CreatorRankingLargeCardView,
parent
)
VIEW_TYPE_COMPACT -> CompactViewHolder(
inflater.inflate(R.layout.view_creator_ranking_compact_card, parent, false) as CreatorRankingCompactCardView,
parent
)
VIEW_TYPE_HORIZONTAL -> HorizontalViewHolder(
inflater.inflate(R.layout.view_creator_ranking_horizontal_card, parent, false) as CreatorRankingHorizontalCardView,
parent
)
else -> error("Unknown viewType: $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = items[position]
when (holder) {
is LargeViewHolder -> holder.bind(item)
is CompactViewHolder -> holder.bind(item)
is HorizontalViewHolder -> holder.bind(item)
}
}
override fun getItemCount(): Int = items.size
fun submitItems(items: List<CreatorRankingItem>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
private inner class LargeViewHolder(
private val view: CreatorRankingLargeCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: CreatorRankingItem) {
bindCommon(view, item, parent)
view.bind(item)
view.setOnCreatorClick(onClickItem)
}
}
private inner class CompactViewHolder(
private val view: CreatorRankingCompactCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: CreatorRankingItem) {
bindCommon(view, item, parent)
view.bind(item)
view.setOnCreatorClick(onClickItem)
}
}
private inner class HorizontalViewHolder(
private val view: CreatorRankingHorizontalCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: CreatorRankingItem) {
bindCommon(view, item, parent)
view.bind(item)
view.setOnCreatorClick(onClickItem)
}
}
private fun bindCommon(
view: CreatorRankingLargeCardView,
item: CreatorRankingItem,
parent: ViewGroup
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadCreatorImage(item)
}
private fun bindCommon(
view: CreatorRankingCompactCardView,
item: CreatorRankingItem,
parent: ViewGroup
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadCreatorImage(item)
}
private fun bindCommon(
view: CreatorRankingHorizontalCardView,
item: CreatorRankingItem,
parent: ViewGroup
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadCreatorImage(item)
}
private fun ImageView.loadCreatorImage(item: CreatorRankingItem) {
loadUrl(item.imageUrl) {
val blurTransformations = CreatorRankingBlur.transformations(context, item.isInaccessible)
if (blurTransformations.isNotEmpty()) {
transformations(blurTransformations)
}
}
}
private fun calculateSize(
item: CreatorRankingItem,
parent: ViewGroup
): CreatorRankingCardSize {
val parentWidth = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels
return CreatorRankingLayoutCalculator.calculate(
parentWidthPx = parentWidth,
parentHorizontalPaddingPx = parent.paddingLeft + parent.paddingRight,
horizontalGapPx = HORIZONTAL_GAP_DP.dpToPx(parent),
placement = CreatorRankingPlacement.fromRank(item.rank)
)
}
private fun Int.dpToPx(parent: ViewGroup): Int = (this * parent.resources.displayMetrics.density).roundToInt()
companion object {
const val GRID_SPAN_COUNT = 6
fun createGridLayoutManager(context: Context): GridLayoutManager = GridLayoutManager(context, GRID_SPAN_COUNT).apply {
spanSizeLookup = createSpanSizeLookup()
}
fun createSpanSizeLookup(): GridLayoutManager.SpanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int = when (CreatorRankingPlacement.fromRank(position + 1).itemsPerRow) {
1 -> GRID_SPAN_COUNT
2 -> GRID_SPAN_COUNT / 2
3 -> GRID_SPAN_COUNT / 3
else -> GRID_SPAN_COUNT
}
}
private const val VIEW_TYPE_LARGE = 1
private const val VIEW_TYPE_COMPACT = 2
private const val VIEW_TYPE_HORIZONTAL = 3
private const val HORIZONTAL_GAP_DP = 4
}
}

View File

@@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.content.Context
import android.os.Build
import coil.transform.Transformation
import kr.co.vividnext.sodalive.common.image.BlurTransformation
internal object CreatorRankingBlur {
fun shouldUseCoilBlur(
enabled: Boolean,
sdkInt: Int = Build.VERSION.SDK_INT
): Boolean = enabled && sdkInt < Build.VERSION_CODES.S
fun transformations(
context: Context,
enabled: Boolean,
sdkInt: Int = Build.VERSION.SDK_INT
): List<Transformation> = if (shouldUseCoilBlur(enabled, sdkInt)) {
listOf(BlurTransformation(context, BLUR_RADIUS, BLUR_SAMPLING))
} else {
emptyList()
}
private const val BLUR_RADIUS = 25f
private const val BLUR_SAMPLING = 2.5f
}

View File

@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
enum class CreatorRankingCardVariant {
Large,
Compact,
Horizontal
}

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
enum class CreatorRankingChangeType {
Increase,
Decrease,
Stay,
New
}

View File

@@ -0,0 +1,176 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.content.Context
import android.graphics.Outline
import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class CreatorRankingCompactCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var image: ImageView? = null
private var dimGradient: View? = null
private var deltaGroup: View? = null
private var rankText: TextView? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var nameText: TextView? = null
private var currentItem: CreatorRankingItem? = null
private var clickListener: ((CreatorRankingItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
image = findViewById(R.id.iv_creator_ranking_image)
dimGradient = findViewById(R.id.v_creator_ranking_dim_gradient)
deltaGroup = findViewById(R.id.ll_creator_ranking_delta)
rankText = findViewById(R.id.tv_creator_ranking_rank)
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
nameText = findViewById(R.id.tv_creator_ranking_name)
clipToOutline = true
outlineProvider = roundOutlineProvider()
imageView().outlineProvider = roundOutlineProvider()
imageView().clipToOutline = true
}
fun bind(item: CreatorRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyCreatorRankingRankGradient()
}
bindDelta(item)
requireNotNull(nameText).apply {
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
dimGradient?.visibility = View.VISIBLE
applyAccessState(item)
}
fun setCardSize(size: CreatorRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
}
positionViews(size)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: CreatorRankingItem) {
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
applyDeltaContainer(presentation)
requireNotNull(deltaIcon).apply {
setImageResource(presentation.iconRes)
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
width = presentation.iconWidthDp.dpToPx()
height = presentation.iconHeightDp.dpToPx()
marginStart = presentation.iconMarginStartDp.dpToPx()
}
}
requireNotNull(deltaAmountText).apply {
text = presentation.amountText.orEmpty()
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
}
}
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
requireNotNull(deltaGroup).apply {
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
}
}
private fun applyAccessState(item: CreatorRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: CreatorRankingCardSize) {
if (size.widthPx <= SMALL_THRESHOLD_PX) {
requireNotNull(rankText).textSize = 40f
requireNotNull(nameText).textSize = 14f
positionSmall(size)
} else {
requireNotNull(rankText).textSize = 54f
requireNotNull(nameText).textSize = 22f
positionMedium(size)
}
requireNotNull(rankText).applyCreatorRankingRankGradient()
}
private fun positionMedium(size: CreatorRankingCardSize) {
val scale = size.widthPx / 185f
requireNotNull(rankText).layoutParams = LayoutParams((55 * scale).roundToInt(), (75 * scale).roundToInt())
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (10 * scale).roundToInt()
topMargin = (70 * scale).roundToInt()
}
requireNotNull(nameText).layoutParams = LayoutParams((165 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = ((size.widthPx - (165 * scale)) / 2f).roundToInt()
topMargin = (145 * scale).roundToInt()
}
}
private fun positionSmall(size: CreatorRankingCardSize) {
val scale = size.widthPx / 122f
requireNotNull(rankText).layoutParams = LayoutParams((42 * scale).roundToInt(), (56 * scale).roundToInt()).apply {
leftMargin = (8 * scale).roundToInt()
}
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (10 * scale).roundToInt()
topMargin = (49 * scale).roundToInt()
}
requireNotNull(nameText).layoutParams = LayoutParams((102 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = ((size.widthPx - (102 * scale)) / 2f).roundToInt()
topMargin = (98 * scale).roundToInt()
}
}
private fun applyBlur(enabled: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
imageView().setRenderEffect(
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
)
}
}
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
}
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
private companion object {
const val SMALL_THRESHOLD_PX = 140
}
}

View File

@@ -0,0 +1,61 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import androidx.annotation.DrawableRes
import kr.co.vividnext.sodalive.R
data class CreatorRankingDeltaPresentation(
@get:DrawableRes val iconRes: Int,
val showAmount: Boolean,
val amountText: String?,
val showPillBackground: Boolean,
val iconWidthDp: Int,
val iconHeightDp: Int,
val iconMarginStartDp: Int
) {
companion object {
fun from(
type: CreatorRankingChangeType,
amount: Int
): CreatorRankingDeltaPresentation = when (type) {
CreatorRankingChangeType.Increase -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_increase,
showAmount = true,
amountText = amount.toString(),
showPillBackground = true,
iconWidthDp = CARET_ICON_SIZE_DP,
iconHeightDp = CARET_ICON_SIZE_DP,
iconMarginStartDp = CARET_ICON_MARGIN_START_DP
)
CreatorRankingChangeType.Decrease -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_decrease,
showAmount = true,
amountText = amount.toString(),
showPillBackground = true,
iconWidthDp = CARET_ICON_SIZE_DP,
iconHeightDp = CARET_ICON_SIZE_DP,
iconMarginStartDp = CARET_ICON_MARGIN_START_DP
)
CreatorRankingChangeType.Stay -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_stay,
showAmount = false,
amountText = null,
showPillBackground = true,
iconWidthDp = CARET_ICON_SIZE_DP,
iconHeightDp = CARET_ICON_SIZE_DP,
iconMarginStartDp = CARET_ICON_MARGIN_START_DP
)
CreatorRankingChangeType.New -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_new,
showAmount = false,
amountText = null,
showPillBackground = false,
iconWidthDp = 36,
iconHeightDp = 23,
iconMarginStartDp = 0
)
}
private const val CARET_ICON_SIZE_DP = 14
private const val CARET_ICON_MARGIN_START_DP = 2
}
}

View File

@@ -0,0 +1,134 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.content.Context
import android.graphics.Outline
import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class CreatorRankingHorizontalCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var rankGroup: View? = null
private var image: ImageView? = null
private var rankText: TextView? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var nameText: TextView? = null
private var currentItem: CreatorRankingItem? = null
private var clickListener: ((CreatorRankingItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
rankGroup = findViewById(R.id.ll_creator_ranking_rank_group)
image = findViewById(R.id.iv_creator_ranking_image)
rankText = findViewById(R.id.tv_creator_ranking_rank)
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
nameText = findViewById(R.id.tv_creator_ranking_name)
imageView().outlineProvider = roundOutlineProvider()
imageView().clipToOutline = true
}
fun bind(item: CreatorRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyCreatorRankingRankGradient()
}
bindDelta(item)
requireNotNull(nameText).text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
applyAccessState(item)
}
fun setCardSize(size: CreatorRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
}
positionViews(size)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: CreatorRankingItem) {
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
applyDeltaContainer(presentation)
requireNotNull(deltaIcon).apply {
setImageResource(presentation.iconRes)
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
width = presentation.iconWidthDp.dpToPx()
height = presentation.iconHeightDp.dpToPx()
marginStart = presentation.iconMarginStartDp.dpToPx()
}
}
requireNotNull(deltaAmountText).apply {
text = presentation.amountText.orEmpty()
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
}
}
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
findViewById<View>(R.id.ll_creator_ranking_delta).apply {
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
}
}
private fun applyAccessState(item: CreatorRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: CreatorRankingCardSize) {
val scale = size.widthPx / 374f
requireNotNull(rankGroup).layoutParams = LayoutParams((49 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = (14 * scale).roundToInt()
topMargin = (14 * scale).roundToInt()
}
requireNotNull(rankText).applyCreatorRankingRankGradient()
imageView().layoutParams = LayoutParams((80 * scale).roundToInt(), (80 * scale).roundToInt()).apply {
leftMargin = (77 * scale).roundToInt()
topMargin = (10 * scale).roundToInt()
}
requireNotNull(nameText).layoutParams = LayoutParams((189 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = (171 * scale).roundToInt()
topMargin = (39 * scale).roundToInt()
}
}
private fun applyBlur(enabled: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
imageView().setRenderEffect(
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
)
}
}
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
}
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
}

View File

@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
data class CreatorRankingItem(
val creatorId: Long,
val rank: Int,
val previousRank: Int?,
val rankChangeType: CreatorRankingChangeType,
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
}
}

View File

@@ -0,0 +1,149 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.content.Context
import android.graphics.Outline
import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class CreatorRankingLargeCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var image: ImageView? = null
private var dimGradient: View? = null
private var deltaGroup: View? = null
private var rankText: TextView? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var nameText: TextView? = null
private var currentItem: CreatorRankingItem? = null
private var clickListener: ((CreatorRankingItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
image = findViewById(R.id.iv_creator_ranking_image)
dimGradient = findViewById(R.id.v_creator_ranking_dim_gradient)
deltaGroup = findViewById(R.id.ll_creator_ranking_delta)
rankText = findViewById(R.id.tv_creator_ranking_rank)
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
nameText = findViewById(R.id.tv_creator_ranking_name)
clipToOutline = true
outlineProvider = roundOutlineProvider()
imageView().outlineProvider = roundOutlineProvider()
imageView().clipToOutline = true
}
fun bind(item: CreatorRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyCreatorRankingRankGradient()
}
bindDelta(item)
requireNotNull(nameText).apply {
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
dimGradient?.visibility = View.VISIBLE
applyAccessState(item)
}
fun setCardSize(size: CreatorRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
}
positionViews(size)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: CreatorRankingItem) {
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
applyDeltaContainer(presentation)
requireNotNull(deltaIcon).apply {
setImageResource(presentation.iconRes)
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
width = presentation.iconWidthDp.dpToPx()
height = presentation.iconHeightDp.dpToPx()
marginStart = presentation.iconMarginStartDp.dpToPx()
}
}
requireNotNull(deltaAmountText).apply {
text = presentation.amountText.orEmpty()
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
}
}
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
requireNotNull(deltaGroup).apply {
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
}
}
private fun applyAccessState(item: CreatorRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: CreatorRankingCardSize) {
val scale = size.widthPx / FIGMA_SIZE.toFloat()
requireNotNull(rankText).layoutParams = LayoutParams((112 * scale).roundToInt(), (124 * scale).roundToInt()).apply {
leftMargin = 0
topMargin = 0
}
requireNotNull(rankText).applyCreatorRankingRankGradient()
requireNotNull(nameText).layoutParams = LayoutParams((334 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = ((size.widthPx - (334 * scale)) / 2f).roundToInt()
topMargin = (305 * scale).roundToInt()
}
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (20 * scale).roundToInt()
topMargin = (122 * scale).roundToInt()
}
}
private fun applyBlur(enabled: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
imageView().setRenderEffect(
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
)
}
}
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
}
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
private companion object {
const val FIGMA_SIZE = 374
}
}

View File

@@ -0,0 +1,35 @@
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,
parentHorizontalPaddingPx: Int = 0,
horizontalGapPx: Int,
placement: CreatorRankingPlacement
): CreatorRankingCardSize {
require(parentWidthPx > 0) { "parentWidthPx must be > 0." }
require(parentHorizontalPaddingPx >= 0) { "parentHorizontalPaddingPx 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 availableWidth = parentWidthPx - parentHorizontalPaddingPx
require(availableWidth > 0) { "available width must be > 0." }
val width = (availableWidth - 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
)

View File

@@ -0,0 +1,18 @@
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)
}
}
}
}

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Shader
import android.widget.TextView
internal fun TextView.applyCreatorRankingRankGradient() {
post {
val gradientHeight = height.takeIf { it > 0 } ?: lineHeight
paint.shader = LinearGradient(
0f,
0f,
0f,
gradientHeight.toFloat(),
Color.WHITE,
Color.parseColor("#EEEEEE"),
Shader.TileMode.CLAMP
)
invalidate()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/gray_900" />
<corners android:radius="4dp" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:startColor="#00000000"
android:centerColor="#00000000"
android:centerY="0.64"
android:endColor="#80000000" />
<corners android:radius="@dimen/radius_14" />
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/gray_900" />
<corners android:radius="@dimen/radius_14" />
</shape>

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingCompactCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_creator_ranking_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<View
android:id="@+id/v_creator_ranking_dim_gradient"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_dim_gradient" />
<TextView
android:id="@+id/tv_creator_ranking_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pattaya_regular"
android:includeFontPadding="false"
android:shadowColor="#7A000000"
android:shadowRadius="4"
android:textColor="@color/white"
android:textSize="54sp"
tools:text="2" />
<LinearLayout
android:id="@+id/ll_creator_ranking_delta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_ranking_delta"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<TextView
android:id="@+id/tv_creator_ranking_delta_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="4" />
<ImageView
android:id="@+id/iv_creator_ranking_delta_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:contentDescription="@null"
tools:src="@drawable/ic_rank_caret_increase" />
</LinearLayout>
<TextView
android:id="@+id/tv_creator_ranking_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="22sp"
tools:text="크리에이터 이름" />
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingCompactCardView>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingHorizontalCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/ll_creator_ranking_rank_group"
android:layout_width="49dp"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_creator_ranking_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pattaya_regular"
android:includeFontPadding="false"
android:shadowColor="#7A000000"
android:shadowRadius="4"
android:textColor="@color/white"
android:textSize="40sp"
tools:text="11" />
<LinearLayout
android:id="@+id/ll_creator_ranking_delta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_ranking_delta"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<TextView
android:id="@+id/tv_creator_ranking_delta_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="4" />
<ImageView
android:id="@+id/iv_creator_ranking_delta_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:contentDescription="@null"
tools:src="@drawable/ic_rank_caret_increase" />
</LinearLayout>
</LinearLayout>
<ImageView
android:id="@+id/iv_creator_ranking_image"
android:layout_width="80dp"
android:layout_height="80dp"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/tv_creator_ranking_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="18sp"
tools:text="크리에이터 이름" />
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingHorizontalCardView>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLargeCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_creator_ranking_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<View
android:id="@+id/v_creator_ranking_dim_gradient"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_dim_gradient" />
<TextView
android:id="@+id/tv_creator_ranking_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pattaya_regular"
android:includeFontPadding="false"
android:shadowColor="#7A000000"
android:shadowRadius="4"
android:textColor="@color/white"
android:textSize="96sp"
tools:text="1" />
<LinearLayout
android:id="@+id/ll_creator_ranking_delta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_ranking_delta"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<TextView
android:id="@+id/tv_creator_ranking_delta_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="4" />
<ImageView
android:id="@+id/iv_creator_ranking_delta_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:contentDescription="@null"
tools:src="@drawable/ic_rank_caret_increase" />
</LinearLayout>
<TextView
android:id="@+id/tv_creator_ranking_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="32sp"
tools:text="크리에이터 이름" />
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLargeCardView>

View File

@@ -1308,6 +1308,7 @@ The upload will continue even if you leave this page.</string>
<string name="audio_content_content_new_title">New shorts</string> <string name="audio_content_content_new_title">New shorts</string>
<string name="audio_content_content_tag_recommend_title">Recommended content by tag</string> <string name="audio_content_content_tag_recommend_title">Recommended content by tag</string>
<string name="audio_content_creator_rank_title">Trending creators</string> <string name="audio_content_creator_rank_title">Trending creators</string>
<string name="creator_ranking_inaccessible_info">This information is not accessible.</string>
<string name="audio_content_detail_age_badge_19">19</string> <string name="audio_content_detail_age_badge_19">19</string>
<string name="audio_content_free_channel_recommend_title">Recommended free content by channel</string> <string name="audio_content_free_channel_recommend_title">Recommended free content by channel</string>
<string name="audio_content_free_creator_intro_title">Creator intro</string> <string name="audio_content_free_creator_intro_title">Creator intro</string>

View File

@@ -1306,6 +1306,7 @@
<string name="audio_content_content_new_title">新しい短編</string> <string name="audio_content_content_new_title">新しい短編</string>
<string name="audio_content_content_tag_recommend_title">タグ別おすすめコンテンツ</string> <string name="audio_content_content_tag_recommend_title">タグ別おすすめコンテンツ</string>
<string name="audio_content_creator_rank_title">人気急上昇</string> <string name="audio_content_creator_rank_title">人気急上昇</string>
<string name="creator_ranking_inaccessible_info">アクセスできない情報です。</string>
<string name="audio_content_detail_age_badge_19">R-18</string> <string name="audio_content_detail_age_badge_19">R-18</string>
<string name="audio_content_free_channel_recommend_title">チャンネル別おすすめ無料コンテンツ</string> <string name="audio_content_free_channel_recommend_title">チャンネル別おすすめ無料コンテンツ</string>
<string name="audio_content_free_creator_intro_title">クリエイター紹介</string> <string name="audio_content_free_creator_intro_title">クリエイター紹介</string>

View File

@@ -1323,6 +1323,7 @@
<string name="audio_content_short_play_title">숏플</string> <string name="audio_content_short_play_title">숏플</string>
<string name="audio_content_new_content_title">새로운 콘텐츠</string> <string name="audio_content_new_content_title">새로운 콘텐츠</string>
<string name="audio_content_main_popular_notice">※ 인기 순위는 매주 업데이트됩니다.</string> <string name="audio_content_main_popular_notice">※ 인기 순위는 매주 업데이트됩니다.</string>
<string name="creator_ranking_inaccessible_info">접근할 수 없는 정보입니다.</string>
<!-- Audio content tabs --> <!-- Audio content tabs -->
<string name="audio_content_alarm_new_title">새로운 알람</string> <string name="audio_content_alarm_new_title">새로운 알람</string>

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import org.junit.Assert.assertEquals
import org.junit.Test
class CreatorRankingAdapterLayoutTest {
@Test
fun `span count supports full two and three column ranking rows`() {
assertEquals(6, CreatorRankingAdapter.GRID_SPAN_COUNT)
}
@Test
fun `span lookup maps rank groups to expected row widths`() {
val spanLookup = CreatorRankingAdapter.createSpanSizeLookup()
assertEquals(6, spanLookup.getSpanSize(0))
(1..6).forEach { position ->
assertEquals(3, spanLookup.getSpanSize(position))
}
(7..9).forEach { position ->
assertEquals(2, spanLookup.getSpanSize(position))
}
listOf(10, 11, 20).forEach { position ->
assertEquals(6, spanLookup.getSpanSize(position))
}
}
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import android.os.Build
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CreatorRankingBlurTest {
@Test
fun `blocked image uses coil blur fallback before Android 12`() {
assertTrue(CreatorRankingBlur.shouldUseCoilBlur(enabled = true, sdkInt = Build.VERSION_CODES.R))
}
@Test
fun `blocked image keeps render effect path on Android 12 or later`() {
assertFalse(CreatorRankingBlur.shouldUseCoilBlur(enabled = true, sdkInt = Build.VERSION_CODES.S))
}
@Test
fun `accessible image does not use coil blur fallback`() {
assertFalse(CreatorRankingBlur.shouldUseCoilBlur(enabled = false, sdkInt = Build.VERSION_CODES.R))
}
}

View File

@@ -0,0 +1,53 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import kr.co.vividnext.sodalive.R
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(CreatorRankingChangeType.Increase, amount = 4)
assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes)
assertTrue(presentation.showAmount)
assertEquals("4", presentation.amountText)
assertTrue(presentation.showPillBackground)
assertEquals(14, presentation.iconWidthDp)
assertEquals(14, presentation.iconHeightDp)
}
@Test
fun `decrease shows caret and amount`() {
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.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(CreatorRankingChangeType.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(CreatorRankingChangeType.New, amount = 0)
assertEquals(R.drawable.ic_rank_new, presentation.iconRes)
assertFalse(presentation.showAmount)
assertNull(presentation.amountText)
assertFalse(presentation.showPillBackground)
assertEquals(36, presentation.iconWidthDp)
assertEquals(23, presentation.iconHeightDp)
}
}

View File

@@ -0,0 +1,66 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking
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: CreatorRankingChangeType = CreatorRankingChangeType.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
)
}

View File

@@ -0,0 +1,68 @@
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 `large card excludes parent horizontal padding from available width`() {
val size = CreatorRankingLayoutCalculator.calculate(
parentWidthPx = 374,
parentHorizontalPaddingPx = 40,
horizontalGapPx = 4,
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
)
assertEquals(334, size.widthPx)
assertEquals(334, 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)
}
}

View File

@@ -0,0 +1,50 @@
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)
}
}

View File

@@ -0,0 +1,800 @@
# 크리에이터 랭킹 위젯 컴포넌트 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에 필요한 순수 데이터 모델과 차단 관계 상태 계산을 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.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`
- 순위 변동 타입별 아이콘과 숫자 표시 여부를 정의한다. `Stay``New`는 숫자를 표시하지 않는다.
- 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`
- [x] **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 이미지 리소스 사용처를 확인한다.
- [x] **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 기준으로 진행하고 검증 기록에 남긴다.
- [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` 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`
- [x] **Step 1: RED - rank별 variant와 row count 테스트 추가**
```kotlin
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)
}
}
```
- [x] **Step 2: RED 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingPlacementTest"`
Expected: `Unresolved reference 'CreatorRankingPlacement'`로 실패한다.
- [x] **Step 3: GREEN - 최소 placement contract 추가**
```kotlin
package kr.co.vividnext.sodalive.v2.widget.creatorranking
enum class CreatorRankingCardVariant {
Large,
Compact,
Horizontal
}
```
```kotlin
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)
}
}
}
}
```
- [x] **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`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt`
- [x] **Step 1: RED - 접근 가능/불가 표시 정책 테스트 추가**
```kotlin
package kr.co.vividnext.sodalive.v2.widget.creatorranking
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: CreatorRankingChangeType = CreatorRankingChangeType.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
)
}
```
- [x] **Step 2: RED 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingItemTest"`
Expected: `Unresolved reference 'CreatorRankingItem'`로 실패한다.
- [x] **Step 3: GREEN - 순수 상태 모델 추가**
```kotlin
package kr.co.vividnext.sodalive.v2.widget.creatorranking
enum class CreatorRankingChangeType {
Increase,
Decrease,
Stay,
New
}
```
```kotlin
package kr.co.vividnext.sodalive.v2.widget.creatorranking
data class CreatorRankingItem(
val creatorId: Long,
val rank: Int,
val previousRank: Int?,
val rankChangeType: CreatorRankingChangeType,
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
}
}
```
- [x] **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`
- [x] **Step 1: RED - 순위 변동 표시 정책 테스트 추가**
```kotlin
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import kr.co.vividnext.sodalive.R
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(CreatorRankingChangeType.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(CreatorRankingChangeType.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(CreatorRankingChangeType.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(CreatorRankingChangeType.New, amount = 0)
assertEquals(R.drawable.ic_rank_new, presentation.iconRes)
assertFalse(presentation.showAmount)
assertNull(presentation.amountText)
}
}
```
- [x] **Step 2: RED 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingDeltaPresentationTest"`
Expected: `Unresolved reference 'CreatorRankingDeltaPresentation'`로 실패한다.
- [x] **Step 3: GREEN - delta presentation contract 추가**
```kotlin
package kr.co.vividnext.sodalive.v2.widget.creatorranking
import androidx.annotation.DrawableRes
import kr.co.vividnext.sodalive.R
data class CreatorRankingDeltaPresentation(
@DrawableRes val iconRes: Int,
val showAmount: Boolean,
val amountText: String?
) {
companion object {
fun from(type: CreatorRankingChangeType, amount: Int): CreatorRankingDeltaPresentation = when (type) {
CreatorRankingChangeType.Increase -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_increase,
showAmount = true,
amountText = amount.toString()
)
CreatorRankingChangeType.Decrease -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_decrease,
showAmount = true,
amountText = amount.toString()
)
CreatorRankingChangeType.Stay -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_stay,
showAmount = false,
amountText = null
)
CreatorRankingChangeType.New -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_new,
showAmount = false,
amountText = null
)
}
}
}
```
- [x] **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`
- [x] **Step 1: RED - 고정 이미지 크기 방지 계산 테스트 추가**
```kotlin
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)
}
}
```
- [x] **Step 2: RED 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLayoutCalculatorTest"`
Expected: `Unresolved reference 'CreatorRankingLayoutCalculator'`로 실패한다.
- [x] **Step 3: GREEN - layout calculator 추가**
```kotlin
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
)
```
- [x] **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`
- [x] **Step 1: 접근 불가 문자열 추가**
Add to `app/src/main/res/values/strings.xml`:
```xml
<string name="creator_ranking_inaccessible_info">접근할 수 없는 정보입니다.</string>
```
Add equivalent keys to localized files:
```xml
<string name="creator_ranking_inaccessible_info">This information is not accessible.</string>
```
```xml
<string name="creator_ranking_inaccessible_info">アクセスできない情報です。</string>
```
- [x] **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는 유지되어야 한다.
- [x] **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는 유지되어야 한다.
- [x] **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에서 계산한다.
- [x] **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 표시 여부를 결정한다.
- `CreatorRankingChangeType.Stay`이면 숫자 없이 `ic_rank_caret_stay`만 표시한다.
- `CreatorRankingChangeType.New`이면 `ic_rank_new`를 표시하고 rank delta 숫자는 숨긴다.
- `Increase`, `Decrease`는 change type별 caret icon과 `rankChangeAmount`를 표시한다.
- `Large``Compact`에서 `item.displayName(...)` 결과가 빈 문자열이면 name TextView를 숨기거나 빈 값으로 둔다.
- `Large``Compact`에서 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`
- [x] **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를 결정한다.
- `Large``CreatorRankingLargeCardView`를 사용한다.
- `Compact``CreatorRankingCompactCardView`를 사용한다.
- `Horizontal``CreatorRankingHorizontalCardView`를 사용한다.
- `item.isTouchable`이 false이면 click callback을 호출하지 않는다.
- 외부에서 전달한 parent width와 row count를 기준으로 `CreatorRankingLayoutCalculator`를 사용한다. parent width를 아직 알 수 없으면 `onBindViewHolder`에서 itemView의 measured width 또는 RecyclerView width를 기준으로 계산한다.
- [x] **Step 2: 기존 화면에는 아직 연결하지 않기**
이번 task에서는 adapter와 reusable widget 추가까지만 수행한다. 기존 `CreatorRankingAdapter` 또는 화면 RecyclerView 교체는 사용자가 별도 승인한 뒤 진행한다.
### Task 8: 검증 및 문서 기록
**Files:**
- Modify: `docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md`
- [x] **Step 1: 단일 테스트 실행**
Run:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"
```
Expected: `BUILD SUCCESSFUL`
- [x] **Step 2: LSP 진단 실행**
Run `lsp_diagnostics` on modified Kotlin/XML files.
Expected: 새 오류가 없다. Kotlin/XML LSP가 환경에 없으면 그 사실을 검증 기록에 남긴다.
- [x] **Step 3: 리소스 병합/디버그 빌드 실행**
Run:
```bash
./gradlew :app:assembleDebug
```
Expected: `BUILD SUCCESSFUL`
- [x] **Step 4: ViewBinding 생성 확인**
Run:
```bash
rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewCreatorRanking(LargeCard|CompactCard|HorizontalCard)Binding"
```
Expected: 신규 layout의 ViewBinding 생성 파일이 출력된다.
- [x] **Step 5: 검증 기록 누적**
문서 하단 `검증 기록`에 실행한 명령, 결과, 빌드 성공 여부, Figma context timeout 여부를 한국어로 기록한다.
## 체크리스트
- [x] AC1: 1위는 `Large` variant로 한 줄에 1개 표시한다.
- [x] AC2: 2위~7위는 `Compact` variant로 한 줄에 2개 표시한다.
- [x] AC3: 8위~10위는 `Compact` variant로 한 줄에 3개 표시한다.
- [x] AC4: 11위 이후는 `Horizontal` variant로 한 줄에 1개 표시한다.
- [x] AC5: 이미지 크기는 고정 dp가 아니라 부모 폭과 row count로 계산한다.
- [x] AC6: 상승/하락/동일/신규 진입 rank delta UI가 각각 `ic_rank_caret_increase`, `ic_rank_caret_decrease`, `ic_rank_caret_stay`, `ic_rank_new`로 표시된다.
- [x] AC6-1: 동일 순위 상태에서는 숫자 없이 `ic_rank_caret_stay`만 표시된다.
- [x] AC7: 차단 관계 상태에서는 이미지가 블러 처리된다.
- [x] AC8: 차단 관계 상태의 1위~10위 카드에는 크리에이터 이름이 표시되지 않는다.
- [x] AC8-1: 차단 관계 상태의 1위~10위 카드에서 이름을 숨겨도 dim gradient 영역은 유지된다.
- [x] AC9: 차단 관계 상태의 11위 이후 카드에는 `접근할 수 없는 정보입니다.`가 표시된다.
- [x] AC10: 차단 관계 상태의 카드는 터치할 수 없다.
- [x] AC11: 접근 가능 상태의 카드는 터치 가능하고 click callback을 호출한다.
- [x] 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 Requirements``isBlockedByMe`, `hasBlockedMe``isBlocked`로 축소했다.
- `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 == Stay``ic_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:assembleDebug``BUILD 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` 경로는 유지했다.
- `CreatorRankingChangeType.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 이슈는 없음을 확인했다.

View File

@@ -0,0 +1,178 @@
# PRD: 크리에이터 랭킹 위젯 컴포넌트
## 1. Overview
Figma `20:3702`, `20:3709`, `20:3711`, `20:3713` 디자인을 기준으로 크리에이터 랭킹을 순위 구간별 카드 형태로 표현하는 Android XML Views 기반 위젯 컴포넌트를 개발한다.
---
## 2. Problem
- 크리에이터 랭킹은 순위 구간에 따라 카드 UI와 한 줄 배치 개수가 달라져야 한다.
- 기기 폭이 하나로 고정되지 않으므로 Figma metadata size를 실제 이미지 크기로 고정하면 다양한 화면 폭에서 재사용하기 어렵다.
- 2위~10위는 순위 구간별 row count는 다르지만 카드 UI는 동일하므로, 별도 medium/small 컴포넌트로 나누면 중복 구현이 생긴다.
- 순위 변동, 신규 진입, 차단 상태, 터치 가능 여부가 함께 표시되어야 하므로 데이터와 UI 상태 계약을 명확히 해야 한다.
---
## 3. Goals
- Figma 4개 노드 기준의 크리에이터 랭킹 카드 variant와 row 배치 정책을 제공한다.
- 이미지 크기는 컴포넌트 내부에서 고정하지 않고 실제 사용하는 row container 폭과 row count에 맞춰 계산한다.
- 순위 구간별 한 줄 배치 규칙을 제공한다.
- 1위: 한 줄에 1개, Figma `20:3702`, 큰 정사각형 카드.
- 2위~7위: 한 줄에 2개, Figma `20:3709`, 기본 정사각형 카드.
- 8위~10위: 한 줄에 3개, Figma `20:3711`, 기본 정사각형 카드.
- 11위 이후: 가로형으로 한 줄에 1개, Figma `20:3713`, 가로형 카드.
- 2위~7위와 8위~10위는 UI가 동일하므로 하나의 기본 정사각형 카드 컴포넌트로 통일한다.
- 1위 큰 카드는 순위 표시 부분이 다르므로 별도 큰 카드 컴포넌트로 작성한다.
- `rank-num` 영역은 이전 순위와 비교한 변동 상태를 표시한다.
- 차단 관계인 크리에이터는 이미지 블러, 이름 비노출 또는 대체문구, 터치 불가 상태로 표시한다.
- 기존 화면 일괄 적용은 구현 계획에서 별도 task로 제한하고, 컴포넌트 계약을 우선 고정한다.
---
## 4. Non-Goals
- 이번 범위에서는 서버 API 설계나 응답 필드명을 확정하지 않는다. 필요한 클라이언트 데이터 계약만 문서화한다.
- 팔로우 버튼, 후원 랭킹, 콘텐츠 랭킹, 라이브 랭킹 UI는 변경하지 않는다.
- Compose 컴포넌트 또는 Compose Theme를 추가하지 않는다.
- Figma에 없는 skeleton loading, shimmer, pressed animation, 별도 badge, 광고 영역, 페이지네이션 UI를 추가하지 않는다.
- 차단/차단 해제 기능 자체를 새로 만들지 않는다.
- 내가 차단했는지, 나를 차단했는지를 UI에서 구분해 표시하지 않는다.
- 이미지 로딩 라이브러리 교체를 수행하지 않는다.
---
## 5. Target Users
- 크리에이터 랭킹 화면을 보는 앱 사용자.
- XML 레이아웃과 RecyclerView 기반 랭킹 UI를 구현/유지보수하는 Android 개발자.
---
## 6. User Stories
- 사용자는 크리에이터 랭킹의 상위 순위를 더 큰 카드로 빠르게 확인하고 싶다.
- 사용자는 순위가 올랐는지, 내려갔는지, 유지됐는지, 신규 진입했는지 알고 싶다.
- 사용자는 차단 관계에 있는 크리에이터의 상세 정보가 노출되지 않기를 기대한다.
- 개발자는 순위 구간별 UI를 하나의 명확한 계약으로 바인딩하고 싶다.
---
## 7. Core Features
### Creator Ranking Widget
크리에이터 랭킹 목록을 순위 구간별 카드 variant와 행 배치 규칙으로 표시한다.
#### Figma References
- Rank 1 large card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3702&m=dev
- Rank 2~7 compact card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3709&m=dev
- Rank 8~10 compact card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3711&m=dev
- Rank 11+ horizontal card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3713&m=dev
#### Variant and Row Requirements
| Rank range | Figma node | Row count | UI variant | Size policy |
| --- | --- | --- | --- | --- |
| 1 | `20:3702` | 한 줄에 1개 | `Large` | 실제 사용 영역 폭을 1등분해 정사각형으로 표시 |
| 2~7 | `20:3709` | 한 줄에 2개 | `Compact` | 실제 사용 영역 폭을 2등분해 정사각형으로 표시 |
| 8~10 | `20:3711` | 한 줄에 3개 | `Compact` | 실제 사용 영역 폭을 3등분해 정사각형으로 표시 |
| 11+ | `20:3713` | 한 줄에 1개 | `Horizontal` | 실제 사용 영역 폭을 1등분하고 Figma 가로형 비율로 표시 |
#### Variant Details
- `Large`: 1위 전용 카드다. 순위 숫자와 순위 변동 표시 영역이 다른 정사각형 카드와 다르므로 별도 layout/custom view로 작성한다.
- `Compact`: 2위~10위 공통 정사각형 카드다. 2위~7위와 8위~10위는 row count만 다르고 UI 구조는 동일하다.
- `Horizontal`: 11위 이후 전용 가로형 카드다.
- Figma metadata size는 참고용 비율 확인에만 사용하고, 구현에서 고정 dp 크기로 사용하지 않는다.
#### Figma Token Requirements
- 공통 카드 이미지 radius는 `radius_14` 또는 `14dp`를 사용한다.
- 공통 dim gradient는 위쪽 `rgba(0,0,0,0)`, 아래쪽 black, opacity `50%`, 전환 시작점 `64.423%` 기준으로 구현한다.
- 공통 `rank-num` 배경은 `gray_900` (`#202020`), radius `4dp`, horizontal padding `4dp`, gap `2dp`를 사용한다.
- 공통 `rank-num` 숫자는 Pretendard Variable Medium, `16sp`, line-height `1.45`, color white를 사용한다.
- 공통 caret icon 크기는 `14dp x 14dp`를 기준으로 한다.
- 순위 숫자는 Pattaya Regular를 사용하고 white~`#EEEEEE` vertical gradient와 `0px 0px 4px rgba(0,0,0,0.48)` shadow를 적용한다.
- `Large` creator name은 Pretendard Variable Bold, `32sp`, line-height `1.45`, color white를 기준으로 한다.
- 2열 `Compact` creator name은 Pretendard Variable Bold, `22sp`, line-height `1.45`, color white를 기준으로 한다.
- 3열 `Compact` creator name은 Pretendard Variable Bold, `14sp`, line-height normal, color white를 기준으로 한다.
- `Horizontal` creator name은 Pretendard Variable Bold, `18sp`, line-height `1.45`, color white를 기준으로 한다.
#### Display Requirements
- 모든 variant는 현재 순위 숫자를 표시한다.
- `rank-num` 영역은 순위 변동 상태를 표시한다.
- 순위 상승: `ic_rank_caret_increase`.
- 순위 하락: `ic_rank_caret_decrease`.
- 순위 동일: 숫자 없이 `ic_rank_caret_stay` 아이콘만 표시.
- 신규 진입: `rank-num` 대신 `ic_rank_new` 이미지.
- 신규 진입이 아니고 순위 동일이 아닌 경우 `rank-num`에는 이전 순위 대비 변동 숫자를 표시한다.
- 1위~10위의 카드 이름 영역은 정상 접근 가능 상태에서만 크리에이터 이름을 표시한다.
- 11위 이후 가로형 카드는 정상 접근 가능 상태에서 크리에이터 이름을 표시한다.
#### Blocked Creator Requirements
- 내가 차단했거나 나를 차단한 크리에이터는 하나의 차단 관계 상태로만 전달받는다.
- 차단 관계 상태에서는 프로필/대표 이미지를 블러 처리한다.
- 차단 관계 상태의 1위~10위 카드는 크리에이터 이름을 표시하지 않는다.
- 차단 관계 상태의 1위~10위 카드는 이름을 숨겨도 하단 dim gradient 영역은 유지한다.
- 차단 관계 상태의 11위 이후 가로형 카드는 크리에이터 이름 대신 `접근할 수 없는 정보입니다.`를 표시한다.
- 차단 관계 상태의 카드는 터치할 수 없다.
- 접근 가능 상태의 카드는 터치할 수 있고, 터치 시 호출부가 크리에이터 상세 이동 등 후속 동작을 처리한다.
#### Data Contract Requirements
- 최소 데이터 계약은 다음 정보를 포함해야 한다.
- `creatorId`: 크리에이터 식별자.
- `rank`: 현재 순위. 1부터 시작한다.
- `previousRank`: 이전 순위. 신규 진입이면 null 허용.
- `rankChangeType`: `increase`, `decrease`, `stay`, `new` 중 하나.
- `rankChangeAmount`: 신규 진입이 아닌 경우 표시할 변동 숫자.
- `creatorName`: 크리에이터 이름.
- `imageUrl`: 카드 이미지 URL.
- `isBlocked`: 내가 차단했거나 나를 차단한 차단 관계 여부.
- UI는 `isBlocked`만 사용하고 차단 방향은 구분하지 않는다.
#### Edge Cases
- 랭킹 데이터가 0개이면 위젯 영역은 표시하지 않거나 호출부의 empty 정책을 따른다.
- 랭킹 데이터가 1~10개이면 존재하는 순위까지만 구간 규칙을 적용한다.
- `rank`가 누락되거나 1보다 작으면 호출부 데이터 오류로 간주하고 해당 항목을 표시하지 않는다.
- `rankChangeType``new`이면 `previousRank``rankChangeAmount`가 없어도 된다.
- `rankChangeType``stay`이면 `rankChangeAmount`가 있어도 숫자를 표시하지 않는다.
- `rankChangeType``increase` 또는 `decrease`인데 `rankChangeAmount`가 없으면 구현 단계에서 데이터 검증 정책을 정한다.
- 이미지 로딩 실패 시 placeholder 정책은 기존 이미지 로딩 계층 또는 호출 화면 정책을 따른다.
---
## 8. UX / UI Expectations
- 전체 위젯은 어두운 배경 위에서 사용하는 것을 전제로 한다.
- Figma 스크린샷 기준 카드 이미지는 rounded corner를 가진다.
- 카드 이미지는 공통 dim gradient를 가진다. 차단 관계 상태에서 이름을 숨기더라도 1위~10위의 gradient overlay는 유지한다.
- `Large` 카드는 1위 전용 순위 표시 형태를 따른다.
- `Compact` 카드는 2위~10위에서 동일 UI를 사용하고, row count에 따라 표시 크기만 달라진다.
- `Horizontal` 카드는 좌측 순위, 중앙 이미지, 우측 이름 영역을 가진다.
- 이미지 크기는 고정 dp로 박지 않고 row container 폭에서 계산한다.
- 정사각형 variant는 계산된 카드 폭과 동일한 높이로 표시한다.
- 가로형 variant는 부모 폭을 채우고 Figma 가로형 비율에 맞는 높이를 유지한다.
- 차단 관계 상태는 사용자가 상세로 들어갈 수 없음을 시각적으로 알 수 있어야 한다.
---
## 9. Technical Constraints
- 현재 프로젝트는 Android XML Views + ViewBinding + RecyclerView 기반이므로 XML 레이아웃과 Kotlin custom view/adapter 패턴을 우선한다.
- 신규 Kotlin 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
- 재사용 가능한 위젯은 `kr.co.vividnext.sodalive.v2.widget` 또는 기능 범위에 맞는 하위 패키지에 둔다.
- 기존 `CreatorRankingAdapter``item_home_creator.xml`는 레거시 홈 크리에이터 카드이므로, 기존 화면 변경 없이 새 컴포넌트의 참고 대상으로만 사용한다.
- 기존 프로젝트의 이미지 로딩 방식이 화면별로 Glide/Coil을 함께 사용하므로, 컴포넌트 내부에 특정 이미지 로더를 강제하지 않는 API를 우선한다.
- 차단 관계 이미지 블러는 기존 `kr.co.vividnext.sodalive.common.image.BlurTransformation` 등 기존 blur 구현의 재사용 가능성을 먼저 검토한다.
- `ic_rank_caret_increase`, `ic_rank_caret_decrease`, `ic_rank_caret_stay`, `ic_rank_new` 리소스가 없으면 구현 단계에서 디자인 에셋 추가가 필요하다.
---
## 10. Metrics
- 순위 구간별 row count가 요구사항과 일치한다.
- 1위는 `Large`, 2위~10위는 `Compact`, 11위 이후는 `Horizontal` variant로 바인딩된다.
- `rankChangeType`별 아이콘/숫자 표시가 문서와 일치한다.
- `stay` 상태에서는 숫자 없이 `ic_rank_caret_stay`만 표시된다.
- 차단 관계 상태에서 이미지 블러, 이름 비노출/대체문구, 터치 불가가 모두 적용된다.
- 차단 관계 상태에서 1위~10위 카드의 gradient overlay가 유지된다.
- 이미지 크기가 고정 dp가 아닌 부모 폭과 row count 기반으로 계산된다.
- 관련 unit test와 Android resource merge/build가 성공한다.
---
## 11. Open Questions
- 서버 응답에 이전 순위, 신규 진입 여부, 차단 관계 여부가 이미 포함되는지 확인이 필요하다. 없으면 API/DTO 확장이 별도 백엔드 협의 항목이다.
- Figma `get_design_context` 재확인 결과 typography/color/radius 토큰은 본 문서의 `Figma Token Requirements`에 반영했다.
- `rankChangeAmount`가 순위 동일일 때는 숫자 없이 `ic_rank_caret_stay` 아이콘만 표시하는 것으로 확정했다.
- 차단 관계 상태에서 1위~10위 카드의 이름 영역을 숨길 때 gradient 영역은 유지하는 것으로 확정했다.