feat(widget): 콘텐츠 랭킹 위젯을 추가한다

This commit is contained in:
2026-05-20 12:00:23 +09:00
parent 01fea58e4c
commit 36ffbc6cdb
35 changed files with 2365 additions and 39 deletions

View File

@@ -0,0 +1,170 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
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 ContentRankingAdapter(
private val onClickItem: (ContentRankingItem) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val items = mutableListOf<ContentRankingItem>()
override fun getItemViewType(position: Int): Int = when (ContentRankingPlacement.fromRank(items[position].rank).variant) {
ContentRankingCardVariant.Large -> VIEW_TYPE_LARGE
ContentRankingCardVariant.MediumGrid -> VIEW_TYPE_MEDIUM_GRID
ContentRankingCardVariant.SmallGrid -> VIEW_TYPE_SMALL_GRID
ContentRankingCardVariant.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_content_ranking_large_card, parent, false) as ContentRankingLargeCardView,
parent
)
VIEW_TYPE_MEDIUM_GRID -> MediumGridViewHolder(
inflater.inflate(R.layout.view_content_ranking_medium_grid_card, parent, false) as ContentRankingMediumGridCardView,
parent
)
VIEW_TYPE_SMALL_GRID -> SmallGridViewHolder(
inflater.inflate(R.layout.view_content_ranking_small_grid_card, parent, false) as ContentRankingSmallGridCardView,
parent
)
VIEW_TYPE_HORIZONTAL -> HorizontalViewHolder(
inflater.inflate(R.layout.view_content_ranking_horizontal_card, parent, false) as ContentRankingHorizontalCardView,
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 MediumGridViewHolder -> holder.bind(item)
is SmallGridViewHolder -> holder.bind(item)
is HorizontalViewHolder -> holder.bind(item)
}
}
override fun getItemCount(): Int = items.size
fun submitItems(items: List<ContentRankingItem>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
private inner class LargeViewHolder(
private val view: ContentRankingLargeCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: ContentRankingItem) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadContentImage(item)
view.backgroundImageView().loadContentImage(item)
view.bind(item)
view.setOnContentClick(onClickItem)
}
}
private inner class MediumGridViewHolder(
private val view: ContentRankingMediumGridCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: ContentRankingItem) {
bindGrid(view, item, parent)
}
}
private inner class SmallGridViewHolder(
private val view: ContentRankingSmallGridCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: ContentRankingItem) {
bindGrid(view, item, parent)
}
}
private inner class HorizontalViewHolder(
private val view: ContentRankingHorizontalCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: ContentRankingItem) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadContentImage(item)
view.bind(item)
view.setOnContentClick(onClickItem)
}
}
private fun bindGrid(
view: ContentRankingGridCardView,
item: ContentRankingItem,
parent: ViewGroup
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadContentImage(item)
view.bind(item)
view.setOnContentClick(onClickItem)
}
private fun ImageView.loadContentImage(item: ContentRankingItem) {
loadUrl(item.imageUrl) {
val blurTransformations = ContentRankingBlur.transformations(context, item.isInaccessible)
if (blurTransformations.isNotEmpty()) {
transformations(blurTransformations)
}
}
}
private fun calculateSize(
item: ContentRankingItem,
parent: ViewGroup
): ContentRankingCardSize {
val parentWidth = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels
return ContentRankingLayoutCalculator.calculate(
parentWidthPx = parentWidth,
parentHorizontalPaddingPx = parent.paddingLeft + parent.paddingRight,
horizontalGapPx = HORIZONTAL_GAP_DP.dpToPx(parent),
placement = ContentRankingPlacement.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 (ContentRankingPlacement.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_MEDIUM_GRID = 2
private const val VIEW_TYPE_SMALL_GRID = 3
private const val VIEW_TYPE_HORIZONTAL = 4
private const val HORIZONTAL_GAP_DP = 4
}
}

View File

@@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import android.content.Context
import android.os.Build
import coil.transform.Transformation
import kr.co.vividnext.sodalive.common.image.BlurTransformation
internal object ContentRankingBlur {
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,8 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
enum class ContentRankingCardVariant {
Large,
MediumGrid,
SmallGrid,
Horizontal
}

View File

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

View File

@@ -0,0 +1,178 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
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
open class ContentRankingGridCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var image: ImageView? = null
private var deltaGroup: View? = null
private var rankText: TextView? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var titleText: TextView? = null
private var creatorText: TextView? = null
private var currentItem: ContentRankingItem? = null
private var clickListener: ((ContentRankingItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
image = findViewById(R.id.iv_content_ranking_image)
deltaGroup = findViewById(R.id.ll_content_ranking_delta)
rankText = findViewById(R.id.tv_content_ranking_rank)
deltaAmountText = findViewById(R.id.tv_content_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_content_ranking_delta_icon)
titleText = findViewById(R.id.tv_content_ranking_title)
creatorText = findViewById(R.id.tv_content_ranking_creator)
clipToOutline = true
outlineProvider = roundOutlineProvider()
imageView().outlineProvider = roundOutlineProvider()
imageView().clipToOutline = true
}
fun bind(item: ContentRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyContentRankingRankGradient()
}
bindDelta(item)
requireNotNull(titleText).apply {
text = item.displayContentName(context.getString(R.string.content_ranking_inaccessible_info))
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
requireNotNull(creatorText).apply {
text = item.displayCreatorName()
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
applyAccessState(item)
}
fun setCardSize(size: ContentRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
}
positionViews(size)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnContentClick(listener: ((ContentRankingItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: ContentRankingItem) {
val presentation = ContentRankingDeltaPresentation.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
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
}
}
private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) {
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: ContentRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: ContentRankingCardSize) {
if (size.widthPx <= SMALL_THRESHOLD_PX) positionSmall(size) else positionMedium(size)
requireNotNull(rankText).applyContentRankingRankGradient()
}
private fun positionMedium(size: ContentRankingCardSize) {
val scale = size.widthPx / 185f
requireNotNull(rankText).apply {
textSize = 54f
layoutParams = LayoutParams((55 * scale).roundToInt(), (75 * scale).roundToInt())
}
requireNotNull(titleText).textSize = 22f
placeDelta(left = 10, top = 70, scale = scale)
placeLabel(width = 165, top = 136, scale = scale, size = size)
}
private fun positionSmall(size: ContentRankingCardSize) {
val scale = size.widthPx / 122f
requireNotNull(rankText).apply {
textSize = 40f
layoutParams = LayoutParams((42 * scale).roundToInt(), (56 * scale).roundToInt()).apply {
leftMargin = (8 * scale).roundToInt()
}
}
requireNotNull(titleText).textSize = 14f
placeDelta(left = 10, top = 49, scale = scale)
placeLabel(width = 102, top = 81, scale = scale, size = size)
}
private fun placeDelta(left: Int, top: Int, scale: Float) {
requireNotNull(deltaGroup).layoutParams = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (left * scale).roundToInt()
topMargin = (top * scale).roundToInt()
}
}
private fun placeLabel(width: Int, top: Int, scale: Float, size: ContentRankingCardSize) {
findViewById<View>(R.id.ll_content_ranking_label).layoutParams = LayoutParams((width * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = ((size.widthPx - (width * scale)) / 2f).roundToInt()
topMargin = (top * 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,148 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
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 ContentRankingHorizontalCardView @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 titleText: TextView? = null
private var creatorText: TextView? = null
private var inaccessibleText: TextView? = null
private var currentItem: ContentRankingItem? = null
private var clickListener: ((ContentRankingItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
rankGroup = findViewById(R.id.ll_content_ranking_rank_group)
image = findViewById(R.id.iv_content_ranking_image)
rankText = findViewById(R.id.tv_content_ranking_rank)
deltaAmountText = findViewById(R.id.tv_content_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_content_ranking_delta_icon)
titleText = findViewById(R.id.tv_content_ranking_title)
creatorText = findViewById(R.id.tv_content_ranking_creator)
inaccessibleText = findViewById(R.id.tv_content_ranking_inaccessible)
imageView().outlineProvider = roundOutlineProvider()
imageView().clipToOutline = true
}
fun bind(item: ContentRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyContentRankingRankGradient()
}
bindDelta(item)
val inaccessibleMessage = context.getString(R.string.content_ranking_inaccessible_info)
requireNotNull(titleText).text = item.displayContentName(inaccessibleMessage)
requireNotNull(creatorText).text = item.displayCreatorName()
requireNotNull(inaccessibleText).text = inaccessibleMessage
titleText?.visibility = if (item.isInaccessible) View.GONE else View.VISIBLE
creatorText?.visibility = if (item.isInaccessible) View.GONE else View.VISIBLE
inaccessibleText?.visibility = if (item.isInaccessible) View.VISIBLE else View.GONE
applyAccessState(item)
}
fun setCardSize(size: ContentRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
}
positionViews(size)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnContentClick(listener: ((ContentRankingItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: ContentRankingItem) {
val presentation = ContentRankingDeltaPresentation.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
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
}
}
private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) {
findViewById<View>(R.id.ll_content_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: ContentRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: ContentRankingCardSize) {
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).applyContentRankingRankGradient()
imageView().layoutParams = LayoutParams((80 * scale).roundToInt(), (80 * scale).roundToInt()).apply {
leftMargin = (77 * scale).roundToInt()
topMargin = (10 * scale).roundToInt()
}
findViewById<View>(R.id.ll_content_ranking_label).layoutParams = LayoutParams((189 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = (171 * scale).roundToInt()
topMargin = (31 * scale).roundToInt()
}
requireNotNull(inaccessibleText).layoutParams = LayoutParams((189 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = (171 * scale).roundToInt()
topMargin = (38 * 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,41 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType
data class ContentRankingItem(
val contentId: String,
val creatorId: String,
val rank: Int,
val previousRank: Int?,
val rankChangeType: RankingChangeType,
val rankChangeAmount: Int?,
val contentName: String,
val creatorName: String,
val imageUrl: String,
val isBlocked: Boolean
) {
init {
require(rank >= 1) { "rank must be greater than or equal to 1." }
}
val isInaccessible: Boolean = isBlocked
val isTouchable: Boolean = !isBlocked
val contentNameMaxLength: Int = when (rank) {
1 -> 16
in 2..10 -> 8
else -> 12
}
fun displayContentName(inaccessibleMessage: String): String = when {
!isBlocked -> contentName.ellipsizeByRank()
rank <= 10 -> ""
else -> inaccessibleMessage
}
fun displayCreatorName(): String = if (isBlocked) "" else creatorName
private fun String.ellipsizeByRank(): String {
if (length <= contentNameMaxLength) return this
return "${take(contentNameMaxLength)}..."
}
}

View File

@@ -0,0 +1,159 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
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 ContentRankingLargeCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var backgroundImage: ImageView? = null
private var contentImage: ImageView? = null
private var deltaGroup: View? = null
private var rankText: TextView? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var titleText: TextView? = null
private var creatorText: TextView? = null
private var currentItem: ContentRankingItem? = null
private var clickListener: ((ContentRankingItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
backgroundImage = findViewById(R.id.iv_content_ranking_background)
contentImage = findViewById(R.id.iv_content_ranking_image)
deltaGroup = findViewById(R.id.ll_content_ranking_delta)
rankText = findViewById(R.id.tv_content_ranking_rank)
deltaAmountText = findViewById(R.id.tv_content_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_content_ranking_delta_icon)
titleText = findViewById(R.id.tv_content_ranking_title)
creatorText = findViewById(R.id.tv_content_ranking_creator)
clipToOutline = true
outlineProvider = roundOutlineProvider()
contentImageView().outlineProvider = roundOutlineProvider()
contentImageView().clipToOutline = true
}
fun bind(item: ContentRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyContentRankingRankGradient()
}
bindDelta(item)
requireNotNull(titleText).apply {
text = item.displayContentName(context.getString(R.string.content_ranking_inaccessible_info))
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
requireNotNull(creatorText).apply {
text = item.displayCreatorName()
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
applyAccessState(item)
}
fun setCardSize(size: ContentRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
}
positionViews(size)
}
fun imageView(): ImageView = contentImageView()
fun backgroundImageView(): ImageView = requireNotNull(backgroundImage)
fun setOnContentClick(listener: ((ContentRankingItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: ContentRankingItem) {
val presentation = ContentRankingDeltaPresentation.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
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
}
}
private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) {
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: ContentRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: ContentRankingCardSize) {
val scale = size.widthPx / FIGMA_WIDTH.toFloat()
requireNotNull(rankText).layoutParams = LayoutParams((91 * scale).roundToInt(), (114 * scale).roundToInt())
contentImageView().layoutParams = LayoutParams((155 * scale).roundToInt(), (154 * scale).roundToInt()).apply {
leftMargin = ((size.widthPx - (155 * scale)) / 2f).roundToInt()
topMargin = (14 * scale).roundToInt()
}
findViewById<View>(R.id.ll_content_ranking_delta).layoutParams = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (20 * scale).roundToInt()
topMargin = (109 * scale).roundToInt()
}
findViewById<View>(R.id.ll_content_ranking_label).layoutParams = LayoutParams((165 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = ((size.widthPx - (165 * scale)) / 2f).roundToInt()
topMargin = (182 * scale).roundToInt()
}
requireNotNull(rankText).applyContentRankingRankGradient()
}
private fun applyBlur(enabled: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val effect = if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
contentImageView().setRenderEffect(effect)
backgroundImageView().setRenderEffect(effect)
}
}
private fun contentImageView(): ImageView = requireNotNull(contentImage)
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_WIDTH = 374
}
}

View File

@@ -0,0 +1,37 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
object ContentRankingLayoutCalculator {
private const val LARGE_FIGMA_WIDTH = 374
private const val LARGE_FIGMA_HEIGHT = 238
private const val HORIZONTAL_FIGMA_WIDTH = 374
private const val HORIZONTAL_FIGMA_HEIGHT = 100
fun calculate(
parentWidthPx: Int,
parentHorizontalPaddingPx: Int = 0,
horizontalGapPx: Int,
placement: ContentRankingPlacement
): ContentRankingCardSize {
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 availableWidth = parentWidthPx - parentHorizontalPaddingPx
require(availableWidth > 0) { "available width must be > 0." }
val totalGap = horizontalGapPx * (placement.itemsPerRow - 1)
val width = (availableWidth - totalGap) / placement.itemsPerRow
val height = when (placement.variant) {
ContentRankingCardVariant.Large -> (width * LARGE_FIGMA_HEIGHT) / LARGE_FIGMA_WIDTH
ContentRankingCardVariant.MediumGrid,
ContentRankingCardVariant.SmallGrid -> width
ContentRankingCardVariant.Horizontal -> (width * HORIZONTAL_FIGMA_HEIGHT) / HORIZONTAL_FIGMA_WIDTH
}
return ContentRankingCardSize(widthPx = width, heightPx = height)
}
}
data class ContentRankingCardSize(
val widthPx: Int,
val heightPx: Int
)

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import android.content.Context
import android.util.AttributeSet
class ContentRankingMediumGridCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ContentRankingGridCardView(context, attrs, defStyleAttr)

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
data class ContentRankingPlacement(
val variant: ContentRankingCardVariant,
val itemsPerRow: Int
) {
companion object {
fun fromRank(rank: Int): ContentRankingPlacement {
require(rank >= 1) { "rank must be greater than or equal to 1." }
return when (rank) {
1 -> ContentRankingPlacement(ContentRankingCardVariant.Large, itemsPerRow = 1)
in 2..7 -> ContentRankingPlacement(ContentRankingCardVariant.MediumGrid, itemsPerRow = 2)
in 8..10 -> ContentRankingPlacement(ContentRankingCardVariant.SmallGrid, itemsPerRow = 3)
else -> ContentRankingPlacement(ContentRankingCardVariant.Horizontal, itemsPerRow = 1)
}
}
}
}

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Shader
import android.widget.TextView
internal fun TextView.applyContentRankingRankGradient() {
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()
}
}

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import android.content.Context
import android.util.AttributeSet
class ContentRankingSmallGridCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ContentRankingGridCardView(context, attrs, defStyleAttr)

View File

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

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.widget.creatorranking
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType
data class CreatorRankingDeltaPresentation( data class CreatorRankingDeltaPresentation(
@get:DrawableRes val iconRes: Int, @get:DrawableRes val iconRes: Int,
@@ -14,10 +15,10 @@ data class CreatorRankingDeltaPresentation(
) { ) {
companion object { companion object {
fun from( fun from(
type: CreatorRankingChangeType, type: RankingChangeType,
amount: Int amount: Int
): CreatorRankingDeltaPresentation = when (type) { ): CreatorRankingDeltaPresentation = when (type) {
CreatorRankingChangeType.Increase -> CreatorRankingDeltaPresentation( RankingChangeType.Increase -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_increase, iconRes = R.drawable.ic_rank_caret_increase,
showAmount = true, showAmount = true,
amountText = amount.toString(), amountText = amount.toString(),
@@ -26,7 +27,7 @@ data class CreatorRankingDeltaPresentation(
iconHeightDp = CARET_ICON_SIZE_DP, iconHeightDp = CARET_ICON_SIZE_DP,
iconMarginStartDp = CARET_ICON_MARGIN_START_DP iconMarginStartDp = CARET_ICON_MARGIN_START_DP
) )
CreatorRankingChangeType.Decrease -> CreatorRankingDeltaPresentation( RankingChangeType.Decrease -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_decrease, iconRes = R.drawable.ic_rank_caret_decrease,
showAmount = true, showAmount = true,
amountText = amount.toString(), amountText = amount.toString(),
@@ -35,7 +36,7 @@ data class CreatorRankingDeltaPresentation(
iconHeightDp = CARET_ICON_SIZE_DP, iconHeightDp = CARET_ICON_SIZE_DP,
iconMarginStartDp = CARET_ICON_MARGIN_START_DP iconMarginStartDp = CARET_ICON_MARGIN_START_DP
) )
CreatorRankingChangeType.Stay -> CreatorRankingDeltaPresentation( RankingChangeType.Stay -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_stay, iconRes = R.drawable.ic_rank_caret_stay,
showAmount = false, showAmount = false,
amountText = null, amountText = null,
@@ -44,7 +45,7 @@ data class CreatorRankingDeltaPresentation(
iconHeightDp = CARET_ICON_SIZE_DP, iconHeightDp = CARET_ICON_SIZE_DP,
iconMarginStartDp = CARET_ICON_MARGIN_START_DP iconMarginStartDp = CARET_ICON_MARGIN_START_DP
) )
CreatorRankingChangeType.New -> CreatorRankingDeltaPresentation( RankingChangeType.New -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_new, iconRes = R.drawable.ic_rank_new,
showAmount = false, showAmount = false,
amountText = null, amountText = null,

View File

@@ -1,10 +1,12 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking package kr.co.vividnext.sodalive.v2.widget.creatorranking
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType
data class CreatorRankingItem( data class CreatorRankingItem(
val creatorId: Long, val creatorId: Long,
val rank: Int, val rank: Int,
val previousRank: Int?, val previousRank: Int?,
val rankChangeType: CreatorRankingChangeType, val rankChangeType: RankingChangeType,
val rankChangeAmount: Int, val rankChangeAmount: Int,
val creatorName: String, val creatorName: String,
val imageUrl: String, val imageUrl: String,

View File

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

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<ImageView
android:id="@+id/iv_content_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_content_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_content_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_content_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_content_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_content_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
android:id="@+id/ll_content_ranking_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_content_ranking_title"
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="콘텐츠 이름" />
<TextView
android:id="@+id/tv_content_ranking_creator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/regular"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="12sp"
tools:text="크리에이터 이름" />
</LinearLayout>
</merge>

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingHorizontalCardView 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_content_ranking_rank_group"
android:layout_width="49dp"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_content_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_content_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_content_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_content_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_content_ranking_image"
android:layout_width="80dp"
android:layout_height="80dp"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<LinearLayout
android:id="@+id/ll_content_ranking_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/tv_content_ranking_title"
android:layout_width="match_parent"
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="콘텐츠 이름" />
<TextView
android:id="@+id/tv_content_ranking_creator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/regular"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="14sp"
tools:text="크리에이터 이름" />
</LinearLayout>
<TextView
android:id="@+id/tv_content_ranking_inaccessible"
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"
android:visibility="gone"
tools:text="접근할 수 없는 정보입니다." />
</kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingHorizontalCardView>

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingLargeCardView 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_content_ranking_background"
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_content_ranking_dim_blur"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A000000" />
<View
android:id="@+id/v_content_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_content_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" />
<ImageView
android:id="@+id/iv_content_ranking_image"
android:layout_width="155dp"
android:layout_height="154dp"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<LinearLayout
android:id="@+id/ll_content_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_content_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_content_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
android:id="@+id/ll_content_ranking_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_content_ranking_title"
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="콘텐츠 이름" />
<TextView
android:id="@+id/tv_content_ranking_creator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/regular"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="12sp"
tools:text="크리에이터 이름" />
</LinearLayout>
</kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingLargeCardView>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingMediumGridCardView 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">
<include layout="@layout/view_content_ranking_grid_card_content" />
</kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingMediumGridCardView>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingSmallGridCardView 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">
<include layout="@layout/view_content_ranking_grid_card_content" />
</kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingSmallGridCardView>

View File

@@ -1309,6 +1309,7 @@ The upload will continue even if you leave this page.</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="creator_ranking_inaccessible_info">This information is not accessible.</string>
<string name="content_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

@@ -1307,6 +1307,7 @@
<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="creator_ranking_inaccessible_info">アクセスできない情報です。</string>
<string name="content_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

@@ -1324,6 +1324,7 @@
<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> <string name="creator_ranking_inaccessible_info">접근할 수 없는 정보입니다.</string>
<string name="content_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,51 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ContentRankingDeltaPresentationTest {
@Test
fun `increase shows amount and increase caret`() {
val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Increase, amount = 4)
assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes)
assertEquals("4", presentation.amountText)
assertTrue(presentation.showAmount)
assertFalse(presentation.replaceWithNewIcon)
}
@Test
fun `decrease shows amount and decrease caret`() {
val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Decrease, amount = 2)
assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes)
assertEquals("2", presentation.amountText)
assertTrue(presentation.showAmount)
assertFalse(presentation.replaceWithNewIcon)
}
@Test
fun `stay shows only stay icon`() {
val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Stay, amount = 0)
assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes)
assertEquals("", presentation.amountText)
assertFalse(presentation.showAmount)
assertFalse(presentation.replaceWithNewIcon)
}
@Test
fun `new replaces rank num with new icon`() {
val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.New, amount = null)
assertEquals(R.drawable.ic_rank_new, presentation.iconRes)
assertEquals("", presentation.amountText)
assertFalse(presentation.showAmount)
assertTrue(presentation.replaceWithNewIcon)
}
}

View File

@@ -0,0 +1,92 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ContentRankingItemTest {
@Test
fun `blocked item is inaccessible`() {
val item = sampleItem(isBlocked = true)
assertTrue(item.isInaccessible)
assertFalse(item.isTouchable)
}
@Test
fun `accessible item is touchable`() {
val item = sampleItem()
assertFalse(item.isInaccessible)
assertTrue(item.isTouchable)
}
@Test
fun `top ten blocked item hides content and creator names`() {
val item = sampleItem(rank = 10, isBlocked = true)
assertEquals("", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
assertEquals("", item.displayCreatorName())
}
@Test
fun `rank 11 blocked item shows inaccessible message as single line`() {
val item = sampleItem(rank = 11, isBlocked = true)
assertEquals("접근할 수 없는 정보입니다.", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
assertEquals("", item.displayCreatorName())
}
@Test
fun `accessible item shows original names`() {
val item = sampleItem(contentName = "콘텐츠 이름", creatorName = "크리에이터 이름")
assertEquals("콘텐츠 이름", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
assertEquals("크리에이터 이름", item.displayCreatorName())
}
@Test
fun `content title max length follows rank range`() {
assertEquals(16, sampleItem(rank = 1).contentNameMaxLength)
assertEquals(8, sampleItem(rank = 2).contentNameMaxLength)
assertEquals(8, sampleItem(rank = 10).contentNameMaxLength)
assertEquals(12, sampleItem(rank = 11).contentNameMaxLength)
}
@Test
fun `accessible content name is truncated by rank range`() {
assertEquals(
"1234567890123456...",
sampleItem(rank = 1, contentName = "12345678901234567").displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")
)
assertEquals(
"12345678...",
sampleItem(rank = 2, contentName = "123456789").displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")
)
assertEquals(
"123456789012...",
sampleItem(rank = 11, contentName = "1234567890123").displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")
)
}
private fun sampleItem(
rank: Int = 1,
contentName: String = "콘텐츠 이름",
creatorName: String = "크리에이터 이름",
isBlocked: Boolean = false
) = ContentRankingItem(
contentId = "content-1",
creatorId = "creator-1",
rank = rank,
previousRank = 5,
rankChangeType = RankingChangeType.Increase,
rankChangeAmount = 4,
contentName = contentName,
creatorName = creatorName,
imageUrl = "https://example.com/image.png",
isBlocked = isBlocked
)
}

View File

@@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import org.junit.Assert.assertEquals
import org.junit.Test
class ContentRankingLayoutCalculatorTest {
@Test
fun `large item keeps figma large ratio`() {
val size = ContentRankingLayoutCalculator.calculate(
parentWidthPx = 374,
horizontalGapPx = 4,
placement = ContentRankingPlacement(ContentRankingCardVariant.Large, itemsPerRow = 1)
)
assertEquals(374, size.widthPx)
assertEquals(238, size.heightPx)
}
@Test
fun `medium grid item width divides available width by items per row`() {
val size = ContentRankingLayoutCalculator.calculate(
parentWidthPx = 374,
horizontalGapPx = 4,
placement = ContentRankingPlacement(ContentRankingCardVariant.MediumGrid, itemsPerRow = 2)
)
assertEquals(185, size.widthPx)
assertEquals(185, size.heightPx)
}
@Test
fun `small grid item subtracts two gaps`() {
val size = ContentRankingLayoutCalculator.calculate(
parentWidthPx = 374,
horizontalGapPx = 4,
placement = ContentRankingPlacement(ContentRankingCardVariant.SmallGrid, itemsPerRow = 3)
)
assertEquals(122, size.widthPx)
assertEquals(122, size.heightPx)
}
@Test
fun `horizontal item keeps figma ratio`() {
val size = ContentRankingLayoutCalculator.calculate(
parentWidthPx = 374,
horizontalGapPx = 4,
placement = ContentRankingPlacement(ContentRankingCardVariant.Horizontal, itemsPerRow = 1)
)
assertEquals(374, size.widthPx)
assertEquals(100, size.heightPx)
}
}

View File

@@ -0,0 +1,50 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import org.junit.Assert.assertEquals
import org.junit.Test
class ContentRankingPlacementTest {
@Test
fun `rank 1 uses large variant and one item row`() {
val placement = ContentRankingPlacement.fromRank(1)
assertEquals(ContentRankingCardVariant.Large, placement.variant)
assertEquals(1, placement.itemsPerRow)
}
@Test
fun `rank 2 to 7 uses medium grid variant and two item row`() {
(2..7).forEach { rank ->
val placement = ContentRankingPlacement.fromRank(rank)
assertEquals(ContentRankingCardVariant.MediumGrid, placement.variant)
assertEquals(2, placement.itemsPerRow)
}
}
@Test
fun `rank 8 to 10 uses small grid variant and three item row`() {
(8..10).forEach { rank ->
val placement = ContentRankingPlacement.fromRank(rank)
assertEquals(ContentRankingCardVariant.SmallGrid, placement.variant)
assertEquals(3, placement.itemsPerRow)
}
}
@Test
fun `rank 11 or greater uses horizontal variant and one item row`() {
listOf(11, 12, 100).forEach { rank ->
val placement = ContentRankingPlacement.fromRank(rank)
assertEquals(ContentRankingCardVariant.Horizontal, placement.variant)
assertEquals(1, placement.itemsPerRow)
}
}
@Test(expected = IllegalArgumentException::class)
fun `rank less than 1 is invalid`() {
ContentRankingPlacement.fromRank(0)
}
}

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking package kr.co.vividnext.sodalive.v2.widget.creatorranking
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -11,7 +12,7 @@ class CreatorRankingDeltaPresentationTest {
@Test @Test
fun `increase shows caret and amount`() { fun `increase shows caret and amount`() {
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Increase, amount = 4) val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Increase, amount = 4)
assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes) assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes)
assertTrue(presentation.showAmount) assertTrue(presentation.showAmount)
@@ -23,7 +24,7 @@ class CreatorRankingDeltaPresentationTest {
@Test @Test
fun `decrease shows caret and amount`() { fun `decrease shows caret and amount`() {
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Decrease, amount = 4) val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Decrease, amount = 4)
assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes) assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes)
assertTrue(presentation.showAmount) assertTrue(presentation.showAmount)
@@ -32,7 +33,7 @@ class CreatorRankingDeltaPresentationTest {
@Test @Test
fun `stay shows stay icon without amount`() { fun `stay shows stay icon without amount`() {
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Stay, amount = 0) val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Stay, amount = 0)
assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes) assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes)
assertFalse(presentation.showAmount) assertFalse(presentation.showAmount)
@@ -41,7 +42,7 @@ class CreatorRankingDeltaPresentationTest {
@Test @Test
fun `new shows new image without amount`() { fun `new shows new image without amount`() {
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.New, amount = 0) val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.New, amount = 0)
assertEquals(R.drawable.ic_rank_new, presentation.iconRes) assertEquals(R.drawable.ic_rank_new, presentation.iconRes)
assertFalse(presentation.showAmount) assertFalse(presentation.showAmount)

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.widget.creatorranking 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.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -48,7 +49,7 @@ class CreatorRankingItemTest {
creatorId: Long = 1L, creatorId: Long = 1L,
rank: Int = 1, rank: Int = 1,
previousRank: Int? = 5, previousRank: Int? = 5,
rankChangeType: CreatorRankingChangeType = CreatorRankingChangeType.Increase, rankChangeType: RankingChangeType = RankingChangeType.Increase,
rankChangeAmount: Int = 4, rankChangeAmount: Int = 4,
creatorName: String = "크리에이터 이름", creatorName: String = "크리에이터 이름",
imageUrl: String = "https://example.com/image.png", imageUrl: String = "https://example.com/image.png",

View File

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

View File

@@ -21,8 +21,8 @@
## 파일 구조 ## 파일 구조
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt`
- 랭킹 UI에 필요한 순수 데이터 모델과 차단 관계 상태 계산을 정의한다. - 랭킹 UI에 필요한 순수 데이터 모델과 차단 관계 상태 계산을 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.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`
- `Increase`, `Decrease`, `Stay`, `New` 순위 변동 타입을 정의한다. - `Increase`, `Decrease`, `Stay`, `New` 순위 변동 타입을 크리에이터/콘텐츠 랭킹 공용 타입으로 정의한다.
- 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/CreatorRankingCardVariant.kt`
- `Large`, `Compact`, `Horizontal` 카드 UI variant를 정의한다. - `Large`, `Compact`, `Horizontal` 카드 UI variant를 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacement.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacement.kt`
@@ -30,7 +30,7 @@
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculator.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculator.kt`
- 부모 폭, horizontal gap, row count 기준으로 item width/height를 계산한다. - 부모 폭, horizontal gap, row count 기준으로 item width/height를 계산한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt`
- 순위 변동 타입별 아이콘과 숫자 표시 여부를 정의한다. `Stay``New`는 숫자를 표시하지 않는다. - 공용 `RankingChangeType`별 아이콘과 숫자 표시 여부를 정의한다. `Stay``New`는 숫자를 표시하지 않는다.
- Create: `app/src/main/res/layout/view_creator_ranking_large_card.xml` - Create: `app/src/main/res/layout/view_creator_ranking_large_card.xml`
- 1위 전용 큰 정사각형 카드 layout을 정의한다. - 1위 전용 큰 정사각형 카드 layout을 정의한다.
- Create: `app/src/main/res/layout/view_creator_ranking_compact_card.xml` - Create: `app/src/main/res/layout/view_creator_ranking_compact_card.xml`
@@ -215,7 +215,7 @@ Expected: `BUILD SUCCESSFUL`
**Files:** **Files:**
- 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/CreatorRankingItemTest.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.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` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt`
- [x] **Step 1: RED - 접근 가능/불가 표시 정책 테스트 추가** - [x] **Step 1: RED - 접근 가능/불가 표시 정책 테스트 추가**
@@ -223,6 +223,7 @@ Expected: `BUILD SUCCESSFUL`
```kotlin ```kotlin
package kr.co.vividnext.sodalive.v2.widget.creatorranking 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.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
@@ -271,7 +272,7 @@ class CreatorRankingItemTest {
creatorId: Long = 1L, creatorId: Long = 1L,
rank: Int = 1, rank: Int = 1,
previousRank: Int? = 5, previousRank: Int? = 5,
rankChangeType: CreatorRankingChangeType = CreatorRankingChangeType.Increase, rankChangeType: RankingChangeType = RankingChangeType.Increase,
rankChangeAmount: Int = 4, rankChangeAmount: Int = 4,
creatorName: String = "크리에이터 이름", creatorName: String = "크리에이터 이름",
imageUrl: String = "https://example.com/image.png", imageUrl: String = "https://example.com/image.png",
@@ -298,9 +299,9 @@ Expected: `Unresolved reference 'CreatorRankingItem'`로 실패한다.
- [x] **Step 3: GREEN - 순수 상태 모델 추가** - [x] **Step 3: GREEN - 순수 상태 모델 추가**
```kotlin ```kotlin
package kr.co.vividnext.sodalive.v2.widget.creatorranking package kr.co.vividnext.sodalive.v2.widget.ranking
enum class CreatorRankingChangeType { enum class RankingChangeType {
Increase, Increase,
Decrease, Decrease,
Stay, Stay,
@@ -311,11 +312,13 @@ enum class CreatorRankingChangeType {
```kotlin ```kotlin
package kr.co.vividnext.sodalive.v2.widget.creatorranking package kr.co.vividnext.sodalive.v2.widget.creatorranking
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType
data class CreatorRankingItem( data class CreatorRankingItem(
val creatorId: Long, val creatorId: Long,
val rank: Int, val rank: Int,
val previousRank: Int?, val previousRank: Int?,
val rankChangeType: CreatorRankingChangeType, val rankChangeType: RankingChangeType,
val rankChangeAmount: Int, val rankChangeAmount: Int,
val creatorName: String, val creatorName: String,
val imageUrl: String, val imageUrl: String,
@@ -354,6 +357,7 @@ Expected: `BUILD SUCCESSFUL`
package kr.co.vividnext.sodalive.v2.widget.creatorranking package kr.co.vividnext.sodalive.v2.widget.creatorranking
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -364,7 +368,7 @@ class CreatorRankingDeltaPresentationTest {
@Test @Test
fun `increase shows caret and amount`() { fun `increase shows caret and amount`() {
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Increase, amount = 4) val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Increase, amount = 4)
assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes) assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes)
assertTrue(presentation.showAmount) assertTrue(presentation.showAmount)
@@ -373,7 +377,7 @@ class CreatorRankingDeltaPresentationTest {
@Test @Test
fun `decrease shows caret and amount`() { fun `decrease shows caret and amount`() {
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Decrease, amount = 4) val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Decrease, amount = 4)
assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes) assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes)
assertTrue(presentation.showAmount) assertTrue(presentation.showAmount)
@@ -382,7 +386,7 @@ class CreatorRankingDeltaPresentationTest {
@Test @Test
fun `stay shows stay icon without amount`() { fun `stay shows stay icon without amount`() {
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Stay, amount = 0) val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Stay, amount = 0)
assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes) assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes)
assertFalse(presentation.showAmount) assertFalse(presentation.showAmount)
@@ -391,7 +395,7 @@ class CreatorRankingDeltaPresentationTest {
@Test @Test
fun `new shows new image without amount`() { fun `new shows new image without amount`() {
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.New, amount = 0) val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.New, amount = 0)
assertEquals(R.drawable.ic_rank_new, presentation.iconRes) assertEquals(R.drawable.ic_rank_new, presentation.iconRes)
assertFalse(presentation.showAmount) assertFalse(presentation.showAmount)
@@ -413,6 +417,7 @@ package kr.co.vividnext.sodalive.v2.widget.creatorranking
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType
data class CreatorRankingDeltaPresentation( data class CreatorRankingDeltaPresentation(
@DrawableRes val iconRes: Int, @DrawableRes val iconRes: Int,
@@ -420,23 +425,23 @@ data class CreatorRankingDeltaPresentation(
val amountText: String? val amountText: String?
) { ) {
companion object { companion object {
fun from(type: CreatorRankingChangeType, amount: Int): CreatorRankingDeltaPresentation = when (type) { fun from(type: RankingChangeType, amount: Int): CreatorRankingDeltaPresentation = when (type) {
CreatorRankingChangeType.Increase -> CreatorRankingDeltaPresentation( RankingChangeType.Increase -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_increase, iconRes = R.drawable.ic_rank_caret_increase,
showAmount = true, showAmount = true,
amountText = amount.toString() amountText = amount.toString()
) )
CreatorRankingChangeType.Decrease -> CreatorRankingDeltaPresentation( RankingChangeType.Decrease -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_decrease, iconRes = R.drawable.ic_rank_caret_decrease,
showAmount = true, showAmount = true,
amountText = amount.toString() amountText = amount.toString()
) )
CreatorRankingChangeType.Stay -> CreatorRankingDeltaPresentation( RankingChangeType.Stay -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_caret_stay, iconRes = R.drawable.ic_rank_caret_stay,
showAmount = false, showAmount = false,
amountText = null amountText = null
) )
CreatorRankingChangeType.New -> CreatorRankingDeltaPresentation( RankingChangeType.New -> CreatorRankingDeltaPresentation(
iconRes = R.drawable.ic_rank_new, iconRes = R.drawable.ic_rank_new,
showAmount = false, showAmount = false,
amountText = null amountText = null
@@ -619,8 +624,8 @@ Required common API:
Required behavior: Required behavior:
- `CreatorRankingDeltaPresentation`을 사용해 rank delta icon과 amount 표시 여부를 결정한다. - `CreatorRankingDeltaPresentation`을 사용해 rank delta icon과 amount 표시 여부를 결정한다.
- `CreatorRankingChangeType.Stay`이면 숫자 없이 `ic_rank_caret_stay`만 표시한다. - `RankingChangeType.Stay`이면 숫자 없이 `ic_rank_caret_stay`만 표시한다.
- `CreatorRankingChangeType.New`이면 `ic_rank_new`를 표시하고 rank delta 숫자는 숨긴다. - `RankingChangeType.New`이면 `ic_rank_new`를 표시하고 rank delta 숫자는 숨긴다.
- `Increase`, `Decrease`는 change type별 caret icon과 `rankChangeAmount`를 표시한다. - `Increase`, `Decrease`는 change type별 caret icon과 `rankChangeAmount`를 표시한다.
- `Large``Compact`에서 `item.displayName(...)` 결과가 빈 문자열이면 name TextView를 숨기거나 빈 값으로 둔다. - `Large``Compact`에서 `item.displayName(...)` 결과가 빈 문자열이면 name TextView를 숨기거나 빈 값으로 둔다.
- `Large``Compact`에서 name TextView를 숨겨도 dim gradient view는 숨기지 않는다. - `Large``Compact`에서 name TextView를 숨겨도 dim gradient view는 숨기지 않는다.
@@ -794,7 +799,7 @@ Expected: 신규 layout의 ViewBinding 생성 파일이 출력된다.
- 결과: - 결과:
- `CreatorRankingAdapter.GRID_SPAN_COUNT`, `createSpanSizeLookup()`, `createGridLayoutManager(context)`를 추가해 1위/11위 이후 full span, 2위~7위 2열, 8위~10위 3열 구성을 호출부가 적용할 수 있게 했다. - `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` 경로는 유지했다. - API 31 미만 차단 이미지에는 기존 `BlurTransformation` 기반 Coil blur fallback을 적용하고, API 31 이상 `RenderEffect` 경로는 유지했다.
- `CreatorRankingChangeType.New`는 pill 배경 없이 `36dp x 23dp` 아이콘으로 표시하고, caret 계열은 기존 `14dp x 14dp` pill 표시를 유지했다. - `RankingChangeType.New`는 pill 배경 없이 `36dp x 23dp` 아이콘으로 표시하고, caret 계열은 기존 `14dp x 14dp` pill 표시를 유지했다.
- creator ranking 단위 테스트와 `:app:assembleDebug`는 모두 `BUILD SUCCESSFUL`로 통과했다. - creator ranking 단위 테스트와 `:app:assembleDebug`는 모두 `BUILD SUCCESSFUL`로 통과했다.
- Kotlin LSP는 현재 환경에 `.kt` 확장자 서버가 없어 `No LSP server configured for extension: .kt`로 진단을 수행할 수 없었다. - Kotlin LSP는 현재 환경에 `.kt` 확장자 서버가 없어 `No LSP server configured for extension: .kt`로 진단을 수행할 수 없었다.
- 보강 후 재리뷰에서 기존 Important 3건은 모두 해소됐고 새 Critical/Important 이슈는 없음을 확인했다. - 보강 후 재리뷰에서 기존 Important 3건은 모두 해소됐고 새 Critical/Important 이슈는 없음을 확인했다.

View File

@@ -0,0 +1,195 @@
# PRD: 콘텐츠 랭킹 위젯 컴포넌트
## 1. Overview
Figma `20:3715`, `20:3718`, `20:3721`, `20:3724` 디자인을 기준으로 콘텐츠 랭킹을 순위 구간별 카드 형태로 표현하는 Android XML Views 기반 위젯 컴포넌트를 개발한다.
---
## 2. Problem
- 콘텐츠 랭킹은 순위 구간에 따라 카드 UI와 한 줄 배치 개수가 달라져야 한다.
- 기기 폭이 하나로 고정되지 않으므로 Figma metadata size를 실제 이미지 크기로 고정하면 다양한 화면 폭에서 재사용하기 어렵다.
- 2위~7위와 8위~10위는 카드 UI와 텍스트 크기가 달라질 수 있어 순위 구간별 표시 contract를 명확히 해야 한다.
- 순위 변동, 신규 진입, 차단 관계, 터치 가능 여부가 함께 표시되어야 하므로 데이터와 UI 상태 계약을 분리해야 한다.
---
## 3. Goals
- Figma 4개 노드 기준의 콘텐츠 랭킹 카드 variant와 row 배치 정책을 제공한다.
- 이미지 크기는 컴포넌트 내부에서 고정하지 않고 실제 사용하는 row container 폭과 row count에 맞춰 계산한다.
- 순위 구간별 한 줄 배치 규칙을 제공한다.
- 1위: 한 줄에 1개, Figma `20:3715`, 큰 콘텐츠 카드.
- 2위~7위: 한 줄에 2개, Figma `20:3718`, 2열 정사각형 카드.
- 8위~10위: 한 줄에 3개, Figma `20:3721`, 3열 정사각형 카드.
- 11위 이후: 가로형으로 한 줄에 1개, Figma `20:3724`, 가로형 카드.
- 콘텐츠명은 순위 구간별 글자 수 기준을 초과하면 한 줄 말줄임 처리한다.
- `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
### Content Ranking Widget
콘텐츠 랭킹 목록을 순위 구간별 카드 variant와 행 배치 규칙으로 표시한다.
#### Figma References
- Rank 1 large content 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-3715&m=dev
- Rank 2~7 two-column content 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-3718&m=dev
- Rank 8~10 three-column content 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-3721&m=dev
- Rank 11+ horizontal content 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-3724&m=dev
#### Variant and Row Requirements
| Rank range | Figma node | Row count | UI variant | Size policy |
| --- | --- | --- | --- | --- |
| 1 | `20:3715` | 한 줄에 1개 | `Large` | 실제 사용 영역 폭을 1등분하고 Figma 큰 카드 비율로 표시 |
| 2~7 | `20:3718` | 한 줄에 2개 | `MediumGrid` | 실제 사용 영역 폭을 2등분해 정사각형으로 표시 |
| 8~10 | `20:3721` | 한 줄에 3개 | `SmallGrid` | 실제 사용 영역 폭을 3등분해 정사각형으로 표시 |
| 11+ | `20:3724` | 한 줄에 1개 | `Horizontal` | 실제 사용 영역 폭을 1등분하고 Figma 가로형 비율로 표시 |
#### Variant Details
- `Large`: 1위 전용 카드다. 배경 영역, 중앙 콘텐츠 이미지, 하단 콘텐츠명/크리에이터명, 순위 숫자, 순위 변동 표시를 포함한다.
- `MediumGrid`: 2위~7위 전용 정사각형 카드다. 2열 배치를 기준으로 콘텐츠명은 `22sp` bold 스타일을 사용한다.
- `SmallGrid`: 8위~10위 전용 정사각형 카드다. 3열 배치를 기준으로 콘텐츠명은 `14sp` bold 스타일을 사용한다.
- `Horizontal`: 11위 이후 전용 가로형 카드다. 좌측 순위/변동, 중앙 이미지, 우측 콘텐츠명/크리에이터명 영역을 가진다.
- Figma metadata size는 참고용 비율 확인에만 사용하고, 구현에서 고정 dp 크기로 사용하지 않는다.
#### Text Requirements
- 모든 텍스트는 `maxLines=1`, `ellipsize=end`로 한 줄 말줄임 처리한다.
- 1위 콘텐츠명은 16자를 초과하면 말줄임 처리한다.
- 2위~10위 콘텐츠명은 8자를 초과하면 말줄임 처리한다.
- 11위 이후 콘텐츠명은 12자를 초과하면 말줄임 처리한다.
- 크리에이터명도 한 줄 제한을 유지하고, 실제 잘림은 레이아웃 폭과 `ellipsize=end`에 따른다.
#### Figma Token Requirements
- 공통 카드 이미지 radius는 `radius_14` 또는 `14dp`를 사용한다.
- 공통 dim gradient는 위쪽 `rgba(0,0,0,0)`, 아래쪽 black, opacity `50%`, 전환 시작점 `64.423%` 기준으로 구현한다.
- 1위 카드에는 Figma 기준 배경 blur/dim 영역과 중앙 콘텐츠 이미지 영역을 함께 둔다.
- 공통 `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` 콘텐츠명은 Pretendard Variable Bold, `22sp`, line-height `1.45`, color white를 기준으로 한다.
- `Large` 크리에이터명은 Pretendard Variable Regular, `12sp`, line-height normal, color white를 기준으로 한다.
- `MediumGrid` 콘텐츠명은 Pretendard Variable Bold, `22sp`, line-height `1.45`, color white를 기준으로 한다.
- `MediumGrid` 크리에이터명은 Pretendard Variable Regular, `12sp`, line-height normal, color white를 기준으로 한다.
- `SmallGrid` 콘텐츠명은 Pretendard Variable Bold, `14sp`, line-height normal, color white를 기준으로 한다.
- `SmallGrid` 크리에이터명은 Pretendard Variable Regular, `12sp`, line-height normal, color white를 기준으로 한다.
- `Horizontal` 콘텐츠명은 Pretendard Variable Bold, `18sp`, line-height `1.45`, color white를 기준으로 한다.
- `Horizontal` 크리에이터명은 Pretendard Variable Regular, `14sp`, line-height `1.45`, color white를 기준으로 한다.
#### Rank Change Requirements
- 모든 variant는 현재 순위 숫자를 표시한다.
- `rank-num` 영역은 순위 변동 상태를 표시한다.
- 순위 상승: `ic_rank_caret_increase`와 변동 숫자를 표시한다.
- 순위 하락: `ic_rank_caret_decrease`와 변동 숫자를 표시한다.
- 순위 동일: 숫자 없이 `ic_rank_caret_stay` 아이콘만 표시한다.
- 신규 진입: `rank-num` 대신 `ic_rank_new` 이미지를 표시한다.
- 신규 진입이 아니고 순위 동일이 아닌 경우 `rank-num`에는 이전 순위 대비 변동 숫자를 표시한다.
#### Blocked Creator Requirements
- 내가 차단했거나 나를 차단한 크리에이터는 하나의 차단 관계 상태로만 전달받는다.
- 차단 관계 상태에서는 콘텐츠 이미지를 블러 처리한다.
- 차단 관계 상태의 1위~10위 카드는 콘텐츠명과 크리에이터 이름을 표시하지 않는다.
- 차단 관계 상태의 1위~10위 카드는 이름 영역을 숨겨도 하단 dim gradient 영역은 유지한다.
- 차단 관계 상태의 11위 이후 가로형 카드는 콘텐츠명과 크리에이터 이름 대신 `접근할 수 없는 정보입니다.` 한 줄만 표시한다.
- 차단 관계 상태의 카드는 터치할 수 없다.
- 접근 가능 상태의 카드는 터치할 수 있고, 터치 시 호출부가 콘텐츠 상세 이동 등 후속 동작을 처리한다.
#### Data Contract Requirements
- 최소 데이터 계약은 다음 정보를 포함해야 한다.
- `contentId`: 콘텐츠 식별자.
- `creatorId`: 크리에이터 식별자.
- `rank`: 현재 순위. 1부터 시작한다.
- `previousRank`: 이전 순위. 신규 진입이면 null 허용.
- `rankChangeType`: `increase`, `decrease`, `stay`, `new` 중 하나.
- `rankChangeAmount`: 신규 진입이 아닌 경우 표시할 변동 숫자.
- `contentName`: 콘텐츠명.
- `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
- 전체 위젯은 어두운 배경 위에서 사용하는 것을 전제로 한다.
- 카드 이미지는 rounded corner를 가진다.
- 카드 이미지는 공통 dim gradient를 가진다. 차단 관계 상태에서 이름을 숨기더라도 1위~10위의 gradient overlay는 유지한다.
- 1위 카드는 배경 영역과 중앙 콘텐츠 이미지가 분리된 형태를 유지한다.
- 2위~7위와 8위~10위는 각각 2열/3열 배치에 맞춰 같은 데이터 계약을 다른 variant로 표시한다.
- 11위 이후 카드는 좌측 순위, 중앙 이미지, 우측 텍스트 영역을 가진다.
- 이미지 크기는 고정 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.contentranking` 또는 기능 범위에 맞는 하위 패키지에 둔다.
- 기존 크리에이터 랭킹 위젯 문서는 참고 대상으로만 사용하고, 콘텐츠 랭킹 contract는 별도 파일로 분리한다.
- 크리에이터 랭킹에서 사용 중인 순위 변동 타입은 `RankingChangeType`처럼 랭킹 공용 이름으로 변경하고, 크리에이터 랭킹과 콘텐츠 랭킹이 동일 타입을 참조하도록 한다.
- 기존 프로젝트의 이미지 로딩 방식이 화면별로 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위~7위는 `MediumGrid`, 8위~10위는 `SmallGrid`, 11위 이후는 `Horizontal` variant로 바인딩된다.
- 콘텐츠명은 순위 구간별 글자 수 기준과 한 줄 말줄임 계약을 만족한다.
- `rankChangeType`별 아이콘/숫자 표시가 문서와 일치한다.
- `stay` 상태에서는 숫자 없이 `ic_rank_caret_stay`만 표시된다.
- `new` 상태에서는 `rank-num` 대신 `ic_rank_new`가 표시된다.
- 차단 관계 상태에서 이미지 블러, 이름 비노출/대체문구, 터치 불가가 모두 적용된다.
- 차단 관계 상태에서 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`에 반영했다.
- 콘텐츠명 글자 수 제한은 사용자 요구사항에 따라 1위 16자, 2위~10위 8자, 11위 이후 12자로 확정한다.
- 차단 관계 상태에서 1위~10위 카드의 이름 영역을 숨길 때 gradient 영역은 유지하는 것으로 확정한다.
- 순위 변동 타입은 크리에이터 랭킹과 콘텐츠 랭킹이 같은 데이터이므로, 구현 시 기존 크리에이터 랭킹 타입을 공용 `RankingChangeType`으로 rename해 재사용하는 것으로 확정한다.

View File

@@ -122,6 +122,7 @@ Figma `20:3702`, `20:3709`, `20:3711`, `20:3713` 디자인을 기준으로 크
- `imageUrl`: 카드 이미지 URL. - `imageUrl`: 카드 이미지 URL.
- `isBlocked`: 내가 차단했거나 나를 차단한 차단 관계 여부. - `isBlocked`: 내가 차단했거나 나를 차단한 차단 관계 여부.
- UI는 `isBlocked`만 사용하고 차단 방향은 구분하지 않는다. - UI는 `isBlocked`만 사용하고 차단 방향은 구분하지 않는다.
- 순위 변동 타입은 크리에이터 랭킹 전용 타입이 아니라 콘텐츠 랭킹과 공유하는 공용 `RankingChangeType`을 사용한다.
#### Edge Cases #### Edge Cases
- 랭킹 데이터가 0개이면 위젯 영역은 표시하지 않거나 호출부의 empty 정책을 따른다. - 랭킹 데이터가 0개이면 위젯 영역은 표시하지 않거나 호출부의 empty 정책을 따른다.
@@ -156,6 +157,7 @@ Figma `20:3702`, `20:3709`, `20:3711`, `20:3713` 디자인을 기준으로 크
- 기존 프로젝트의 이미지 로딩 방식이 화면별로 Glide/Coil을 함께 사용하므로, 컴포넌트 내부에 특정 이미지 로더를 강제하지 않는 API를 우선한다. - 기존 프로젝트의 이미지 로딩 방식이 화면별로 Glide/Coil을 함께 사용하므로, 컴포넌트 내부에 특정 이미지 로더를 강제하지 않는 API를 우선한다.
- 차단 관계 이미지 블러는 기존 `kr.co.vividnext.sodalive.common.image.BlurTransformation` 등 기존 blur 구현의 재사용 가능성을 먼저 검토한다. - 차단 관계 이미지 블러는 기존 `kr.co.vividnext.sodalive.common.image.BlurTransformation` 등 기존 blur 구현의 재사용 가능성을 먼저 검토한다.
- `ic_rank_caret_increase`, `ic_rank_caret_decrease`, `ic_rank_caret_stay`, `ic_rank_new` 리소스가 없으면 구현 단계에서 디자인 에셋 추가가 필요하다. - `ic_rank_caret_increase`, `ic_rank_caret_decrease`, `ic_rank_caret_stay`, `ic_rank_new` 리소스가 없으면 구현 단계에서 디자인 에셋 추가가 필요하다.
- 기존 `CreatorRankingChangeType``RankingChangeType`으로 rename/move해 크리에이터 랭킹과 콘텐츠 랭킹이 같은 타입을 참조하도록 한다.
--- ---
@@ -176,3 +178,4 @@ Figma `20:3702`, `20:3709`, `20:3711`, `20:3713` 디자인을 기준으로 크
- Figma `get_design_context` 재확인 결과 typography/color/radius 토큰은 본 문서의 `Figma Token Requirements`에 반영했다. - Figma `get_design_context` 재확인 결과 typography/color/radius 토큰은 본 문서의 `Figma Token Requirements`에 반영했다.
- `rankChangeAmount`가 순위 동일일 때는 숫자 없이 `ic_rank_caret_stay` 아이콘만 표시하는 것으로 확정했다. - `rankChangeAmount`가 순위 동일일 때는 숫자 없이 `ic_rank_caret_stay` 아이콘만 표시하는 것으로 확정했다.
- 차단 관계 상태에서 1위~10위 카드의 이름 영역을 숨길 때 gradient 영역은 유지하는 것으로 확정했다. - 차단 관계 상태에서 1위~10위 카드의 이름 영역을 숨길 때 gradient 영역은 유지하는 것으로 확정했다.
- 순위 변동 타입은 콘텐츠 랭킹과 같은 데이터이므로 공용 `RankingChangeType`으로 사용하는 것으로 확정했다.