feat(widget): 크리에이터 랭킹 위젯을 추가한다
This commit is contained in:
@@ -0,0 +1,169 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kr.co.vividnext.sodalive.extensions.loadUrl
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class CreatorRankingAdapter(
|
||||||
|
private val onClickItem: (CreatorRankingItem) -> Unit
|
||||||
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private val items = mutableListOf<CreatorRankingItem>()
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int): Int = when (CreatorRankingPlacement.fromRank(items[position].rank).variant) {
|
||||||
|
CreatorRankingCardVariant.Large -> VIEW_TYPE_LARGE
|
||||||
|
CreatorRankingCardVariant.Compact -> VIEW_TYPE_COMPACT
|
||||||
|
CreatorRankingCardVariant.Horizontal -> VIEW_TYPE_HORIZONTAL
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return when (viewType) {
|
||||||
|
VIEW_TYPE_LARGE -> LargeViewHolder(
|
||||||
|
inflater.inflate(R.layout.view_creator_ranking_large_card, parent, false) as CreatorRankingLargeCardView,
|
||||||
|
parent
|
||||||
|
)
|
||||||
|
VIEW_TYPE_COMPACT -> CompactViewHolder(
|
||||||
|
inflater.inflate(R.layout.view_creator_ranking_compact_card, parent, false) as CreatorRankingCompactCardView,
|
||||||
|
parent
|
||||||
|
)
|
||||||
|
VIEW_TYPE_HORIZONTAL -> HorizontalViewHolder(
|
||||||
|
inflater.inflate(R.layout.view_creator_ranking_horizontal_card, parent, false) as CreatorRankingHorizontalCardView,
|
||||||
|
parent
|
||||||
|
)
|
||||||
|
else -> error("Unknown viewType: $viewType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
val item = items[position]
|
||||||
|
when (holder) {
|
||||||
|
is LargeViewHolder -> holder.bind(item)
|
||||||
|
is CompactViewHolder -> holder.bind(item)
|
||||||
|
is HorizontalViewHolder -> holder.bind(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = items.size
|
||||||
|
|
||||||
|
fun submitItems(items: List<CreatorRankingItem>) {
|
||||||
|
this.items.clear()
|
||||||
|
this.items.addAll(items)
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class LargeViewHolder(
|
||||||
|
private val view: CreatorRankingLargeCardView,
|
||||||
|
private val parent: ViewGroup
|
||||||
|
) : RecyclerView.ViewHolder(view) {
|
||||||
|
fun bind(item: CreatorRankingItem) {
|
||||||
|
bindCommon(view, item, parent)
|
||||||
|
view.bind(item)
|
||||||
|
view.setOnCreatorClick(onClickItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class CompactViewHolder(
|
||||||
|
private val view: CreatorRankingCompactCardView,
|
||||||
|
private val parent: ViewGroup
|
||||||
|
) : RecyclerView.ViewHolder(view) {
|
||||||
|
fun bind(item: CreatorRankingItem) {
|
||||||
|
bindCommon(view, item, parent)
|
||||||
|
view.bind(item)
|
||||||
|
view.setOnCreatorClick(onClickItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class HorizontalViewHolder(
|
||||||
|
private val view: CreatorRankingHorizontalCardView,
|
||||||
|
private val parent: ViewGroup
|
||||||
|
) : RecyclerView.ViewHolder(view) {
|
||||||
|
fun bind(item: CreatorRankingItem) {
|
||||||
|
bindCommon(view, item, parent)
|
||||||
|
view.bind(item)
|
||||||
|
view.setOnCreatorClick(onClickItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindCommon(
|
||||||
|
view: CreatorRankingLargeCardView,
|
||||||
|
item: CreatorRankingItem,
|
||||||
|
parent: ViewGroup
|
||||||
|
) {
|
||||||
|
val size = calculateSize(item, parent)
|
||||||
|
view.setCardSize(size)
|
||||||
|
view.imageView().loadCreatorImage(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindCommon(
|
||||||
|
view: CreatorRankingCompactCardView,
|
||||||
|
item: CreatorRankingItem,
|
||||||
|
parent: ViewGroup
|
||||||
|
) {
|
||||||
|
val size = calculateSize(item, parent)
|
||||||
|
view.setCardSize(size)
|
||||||
|
view.imageView().loadCreatorImage(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindCommon(
|
||||||
|
view: CreatorRankingHorizontalCardView,
|
||||||
|
item: CreatorRankingItem,
|
||||||
|
parent: ViewGroup
|
||||||
|
) {
|
||||||
|
val size = calculateSize(item, parent)
|
||||||
|
view.setCardSize(size)
|
||||||
|
view.imageView().loadCreatorImage(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ImageView.loadCreatorImage(item: CreatorRankingItem) {
|
||||||
|
loadUrl(item.imageUrl) {
|
||||||
|
val blurTransformations = CreatorRankingBlur.transformations(context, item.isInaccessible)
|
||||||
|
if (blurTransformations.isNotEmpty()) {
|
||||||
|
transformations(blurTransformations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateSize(
|
||||||
|
item: CreatorRankingItem,
|
||||||
|
parent: ViewGroup
|
||||||
|
): CreatorRankingCardSize {
|
||||||
|
val parentWidth = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels
|
||||||
|
return CreatorRankingLayoutCalculator.calculate(
|
||||||
|
parentWidthPx = parentWidth,
|
||||||
|
parentHorizontalPaddingPx = parent.paddingLeft + parent.paddingRight,
|
||||||
|
horizontalGapPx = HORIZONTAL_GAP_DP.dpToPx(parent),
|
||||||
|
placement = CreatorRankingPlacement.fromRank(item.rank)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.dpToPx(parent: ViewGroup): Int = (this * parent.resources.displayMetrics.density).roundToInt()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val GRID_SPAN_COUNT = 6
|
||||||
|
|
||||||
|
fun createGridLayoutManager(context: Context): GridLayoutManager = GridLayoutManager(context, GRID_SPAN_COUNT).apply {
|
||||||
|
spanSizeLookup = createSpanSizeLookup()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSpanSizeLookup(): GridLayoutManager.SpanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
|
||||||
|
override fun getSpanSize(position: Int): Int = when (CreatorRankingPlacement.fromRank(position + 1).itemsPerRow) {
|
||||||
|
1 -> GRID_SPAN_COUNT
|
||||||
|
2 -> GRID_SPAN_COUNT / 2
|
||||||
|
3 -> GRID_SPAN_COUNT / 3
|
||||||
|
else -> GRID_SPAN_COUNT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val VIEW_TYPE_LARGE = 1
|
||||||
|
private const val VIEW_TYPE_COMPACT = 2
|
||||||
|
private const val VIEW_TYPE_HORIZONTAL = 3
|
||||||
|
private const val HORIZONTAL_GAP_DP = 4
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import coil.transform.Transformation
|
||||||
|
import kr.co.vividnext.sodalive.common.image.BlurTransformation
|
||||||
|
|
||||||
|
internal object CreatorRankingBlur {
|
||||||
|
|
||||||
|
fun shouldUseCoilBlur(
|
||||||
|
enabled: Boolean,
|
||||||
|
sdkInt: Int = Build.VERSION.SDK_INT
|
||||||
|
): Boolean = enabled && sdkInt < Build.VERSION_CODES.S
|
||||||
|
|
||||||
|
fun transformations(
|
||||||
|
context: Context,
|
||||||
|
enabled: Boolean,
|
||||||
|
sdkInt: Int = Build.VERSION.SDK_INT
|
||||||
|
): List<Transformation> = if (shouldUseCoilBlur(enabled, sdkInt)) {
|
||||||
|
listOf(BlurTransformation(context, BLUR_RADIUS, BLUR_SAMPLING))
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val BLUR_RADIUS = 25f
|
||||||
|
private const val BLUR_SAMPLING = 2.5f
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
enum class CreatorRankingCardVariant {
|
||||||
|
Large,
|
||||||
|
Compact,
|
||||||
|
Horizontal
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
enum class CreatorRankingChangeType {
|
||||||
|
Increase,
|
||||||
|
Decrease,
|
||||||
|
Stay,
|
||||||
|
New
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Outline
|
||||||
|
import android.graphics.RenderEffect
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewOutlineProvider
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class CreatorRankingCompactCardView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private var image: ImageView? = null
|
||||||
|
private var dimGradient: View? = null
|
||||||
|
private var deltaGroup: View? = null
|
||||||
|
private var rankText: TextView? = null
|
||||||
|
private var deltaAmountText: TextView? = null
|
||||||
|
private var deltaIcon: ImageView? = null
|
||||||
|
private var nameText: TextView? = null
|
||||||
|
private var currentItem: CreatorRankingItem? = null
|
||||||
|
private var clickListener: ((CreatorRankingItem) -> Unit)? = null
|
||||||
|
|
||||||
|
override fun onFinishInflate() {
|
||||||
|
super.onFinishInflate()
|
||||||
|
image = findViewById(R.id.iv_creator_ranking_image)
|
||||||
|
dimGradient = findViewById(R.id.v_creator_ranking_dim_gradient)
|
||||||
|
deltaGroup = findViewById(R.id.ll_creator_ranking_delta)
|
||||||
|
rankText = findViewById(R.id.tv_creator_ranking_rank)
|
||||||
|
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
|
||||||
|
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
|
||||||
|
nameText = findViewById(R.id.tv_creator_ranking_name)
|
||||||
|
clipToOutline = true
|
||||||
|
outlineProvider = roundOutlineProvider()
|
||||||
|
imageView().outlineProvider = roundOutlineProvider()
|
||||||
|
imageView().clipToOutline = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(item: CreatorRankingItem) {
|
||||||
|
currentItem = item
|
||||||
|
requireNotNull(rankText).apply {
|
||||||
|
text = item.rank.toString()
|
||||||
|
applyCreatorRankingRankGradient()
|
||||||
|
}
|
||||||
|
bindDelta(item)
|
||||||
|
requireNotNull(nameText).apply {
|
||||||
|
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
|
||||||
|
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
|
||||||
|
}
|
||||||
|
dimGradient?.visibility = View.VISIBLE
|
||||||
|
applyAccessState(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCardSize(size: CreatorRankingCardSize) {
|
||||||
|
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
|
||||||
|
width = size.widthPx
|
||||||
|
height = size.heightPx
|
||||||
|
}
|
||||||
|
positionViews(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun imageView(): ImageView = requireNotNull(image)
|
||||||
|
|
||||||
|
fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) {
|
||||||
|
clickListener = listener
|
||||||
|
currentItem?.let(::applyAccessState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindDelta(item: CreatorRankingItem) {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||||
|
applyDeltaContainer(presentation)
|
||||||
|
requireNotNull(deltaIcon).apply {
|
||||||
|
setImageResource(presentation.iconRes)
|
||||||
|
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||||
|
width = presentation.iconWidthDp.dpToPx()
|
||||||
|
height = presentation.iconHeightDp.dpToPx()
|
||||||
|
marginStart = presentation.iconMarginStartDp.dpToPx()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requireNotNull(deltaAmountText).apply {
|
||||||
|
text = presentation.amountText.orEmpty()
|
||||||
|
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
|
||||||
|
requireNotNull(deltaGroup).apply {
|
||||||
|
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||||
|
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
||||||
|
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyAccessState(item: CreatorRankingItem) {
|
||||||
|
applyBlur(item.isInaccessible)
|
||||||
|
isClickable = item.isTouchable && clickListener != null
|
||||||
|
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun positionViews(size: CreatorRankingCardSize) {
|
||||||
|
if (size.widthPx <= SMALL_THRESHOLD_PX) {
|
||||||
|
requireNotNull(rankText).textSize = 40f
|
||||||
|
requireNotNull(nameText).textSize = 14f
|
||||||
|
positionSmall(size)
|
||||||
|
} else {
|
||||||
|
requireNotNull(rankText).textSize = 54f
|
||||||
|
requireNotNull(nameText).textSize = 22f
|
||||||
|
positionMedium(size)
|
||||||
|
}
|
||||||
|
requireNotNull(rankText).applyCreatorRankingRankGradient()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun positionMedium(size: CreatorRankingCardSize) {
|
||||||
|
val scale = size.widthPx / 185f
|
||||||
|
requireNotNull(rankText).layoutParams = LayoutParams((55 * scale).roundToInt(), (75 * scale).roundToInt())
|
||||||
|
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
leftMargin = (10 * scale).roundToInt()
|
||||||
|
topMargin = (70 * scale).roundToInt()
|
||||||
|
}
|
||||||
|
requireNotNull(nameText).layoutParams = LayoutParams((165 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
leftMargin = ((size.widthPx - (165 * scale)) / 2f).roundToInt()
|
||||||
|
topMargin = (145 * scale).roundToInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun positionSmall(size: CreatorRankingCardSize) {
|
||||||
|
val scale = size.widthPx / 122f
|
||||||
|
requireNotNull(rankText).layoutParams = LayoutParams((42 * scale).roundToInt(), (56 * scale).roundToInt()).apply {
|
||||||
|
leftMargin = (8 * scale).roundToInt()
|
||||||
|
}
|
||||||
|
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
leftMargin = (10 * scale).roundToInt()
|
||||||
|
topMargin = (49 * scale).roundToInt()
|
||||||
|
}
|
||||||
|
requireNotNull(nameText).layoutParams = LayoutParams((102 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
leftMargin = ((size.widthPx - (102 * scale)) / 2f).roundToInt()
|
||||||
|
topMargin = (98 * scale).roundToInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyBlur(enabled: Boolean) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
imageView().setRenderEffect(
|
||||||
|
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
|
||||||
|
override fun getOutline(view: View, outline: Outline) {
|
||||||
|
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SMALL_THRESHOLD_PX = 140
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
|
||||||
|
data class CreatorRankingDeltaPresentation(
|
||||||
|
@get:DrawableRes val iconRes: Int,
|
||||||
|
val showAmount: Boolean,
|
||||||
|
val amountText: String?,
|
||||||
|
val showPillBackground: Boolean,
|
||||||
|
val iconWidthDp: Int,
|
||||||
|
val iconHeightDp: Int,
|
||||||
|
val iconMarginStartDp: Int
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(
|
||||||
|
type: CreatorRankingChangeType,
|
||||||
|
amount: Int
|
||||||
|
): CreatorRankingDeltaPresentation = when (type) {
|
||||||
|
CreatorRankingChangeType.Increase -> CreatorRankingDeltaPresentation(
|
||||||
|
iconRes = R.drawable.ic_rank_caret_increase,
|
||||||
|
showAmount = true,
|
||||||
|
amountText = amount.toString(),
|
||||||
|
showPillBackground = true,
|
||||||
|
iconWidthDp = CARET_ICON_SIZE_DP,
|
||||||
|
iconHeightDp = CARET_ICON_SIZE_DP,
|
||||||
|
iconMarginStartDp = CARET_ICON_MARGIN_START_DP
|
||||||
|
)
|
||||||
|
CreatorRankingChangeType.Decrease -> CreatorRankingDeltaPresentation(
|
||||||
|
iconRes = R.drawable.ic_rank_caret_decrease,
|
||||||
|
showAmount = true,
|
||||||
|
amountText = amount.toString(),
|
||||||
|
showPillBackground = true,
|
||||||
|
iconWidthDp = CARET_ICON_SIZE_DP,
|
||||||
|
iconHeightDp = CARET_ICON_SIZE_DP,
|
||||||
|
iconMarginStartDp = CARET_ICON_MARGIN_START_DP
|
||||||
|
)
|
||||||
|
CreatorRankingChangeType.Stay -> CreatorRankingDeltaPresentation(
|
||||||
|
iconRes = R.drawable.ic_rank_caret_stay,
|
||||||
|
showAmount = false,
|
||||||
|
amountText = null,
|
||||||
|
showPillBackground = true,
|
||||||
|
iconWidthDp = CARET_ICON_SIZE_DP,
|
||||||
|
iconHeightDp = CARET_ICON_SIZE_DP,
|
||||||
|
iconMarginStartDp = CARET_ICON_MARGIN_START_DP
|
||||||
|
)
|
||||||
|
CreatorRankingChangeType.New -> CreatorRankingDeltaPresentation(
|
||||||
|
iconRes = R.drawable.ic_rank_new,
|
||||||
|
showAmount = false,
|
||||||
|
amountText = null,
|
||||||
|
showPillBackground = false,
|
||||||
|
iconWidthDp = 36,
|
||||||
|
iconHeightDp = 23,
|
||||||
|
iconMarginStartDp = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CARET_ICON_SIZE_DP = 14
|
||||||
|
private const val CARET_ICON_MARGIN_START_DP = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Outline
|
||||||
|
import android.graphics.RenderEffect
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewOutlineProvider
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class CreatorRankingHorizontalCardView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private var rankGroup: View? = null
|
||||||
|
private var image: ImageView? = null
|
||||||
|
private var rankText: TextView? = null
|
||||||
|
private var deltaAmountText: TextView? = null
|
||||||
|
private var deltaIcon: ImageView? = null
|
||||||
|
private var nameText: TextView? = null
|
||||||
|
private var currentItem: CreatorRankingItem? = null
|
||||||
|
private var clickListener: ((CreatorRankingItem) -> Unit)? = null
|
||||||
|
|
||||||
|
override fun onFinishInflate() {
|
||||||
|
super.onFinishInflate()
|
||||||
|
rankGroup = findViewById(R.id.ll_creator_ranking_rank_group)
|
||||||
|
image = findViewById(R.id.iv_creator_ranking_image)
|
||||||
|
rankText = findViewById(R.id.tv_creator_ranking_rank)
|
||||||
|
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
|
||||||
|
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
|
||||||
|
nameText = findViewById(R.id.tv_creator_ranking_name)
|
||||||
|
imageView().outlineProvider = roundOutlineProvider()
|
||||||
|
imageView().clipToOutline = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(item: CreatorRankingItem) {
|
||||||
|
currentItem = item
|
||||||
|
requireNotNull(rankText).apply {
|
||||||
|
text = item.rank.toString()
|
||||||
|
applyCreatorRankingRankGradient()
|
||||||
|
}
|
||||||
|
bindDelta(item)
|
||||||
|
requireNotNull(nameText).text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
|
||||||
|
applyAccessState(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCardSize(size: CreatorRankingCardSize) {
|
||||||
|
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
|
||||||
|
width = size.widthPx
|
||||||
|
height = size.heightPx
|
||||||
|
}
|
||||||
|
positionViews(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun imageView(): ImageView = requireNotNull(image)
|
||||||
|
|
||||||
|
fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) {
|
||||||
|
clickListener = listener
|
||||||
|
currentItem?.let(::applyAccessState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindDelta(item: CreatorRankingItem) {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||||
|
applyDeltaContainer(presentation)
|
||||||
|
requireNotNull(deltaIcon).apply {
|
||||||
|
setImageResource(presentation.iconRes)
|
||||||
|
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||||
|
width = presentation.iconWidthDp.dpToPx()
|
||||||
|
height = presentation.iconHeightDp.dpToPx()
|
||||||
|
marginStart = presentation.iconMarginStartDp.dpToPx()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requireNotNull(deltaAmountText).apply {
|
||||||
|
text = presentation.amountText.orEmpty()
|
||||||
|
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
|
||||||
|
findViewById<View>(R.id.ll_creator_ranking_delta).apply {
|
||||||
|
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||||
|
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
||||||
|
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyAccessState(item: CreatorRankingItem) {
|
||||||
|
applyBlur(item.isInaccessible)
|
||||||
|
isClickable = item.isTouchable && clickListener != null
|
||||||
|
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun positionViews(size: CreatorRankingCardSize) {
|
||||||
|
val scale = size.widthPx / 374f
|
||||||
|
requireNotNull(rankGroup).layoutParams = LayoutParams((49 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
leftMargin = (14 * scale).roundToInt()
|
||||||
|
topMargin = (14 * scale).roundToInt()
|
||||||
|
}
|
||||||
|
requireNotNull(rankText).applyCreatorRankingRankGradient()
|
||||||
|
imageView().layoutParams = LayoutParams((80 * scale).roundToInt(), (80 * scale).roundToInt()).apply {
|
||||||
|
leftMargin = (77 * scale).roundToInt()
|
||||||
|
topMargin = (10 * scale).roundToInt()
|
||||||
|
}
|
||||||
|
requireNotNull(nameText).layoutParams = LayoutParams((189 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
leftMargin = (171 * scale).roundToInt()
|
||||||
|
topMargin = (39 * scale).roundToInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyBlur(enabled: Boolean) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
imageView().setRenderEffect(
|
||||||
|
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
|
||||||
|
override fun getOutline(view: View, outline: Outline) {
|
||||||
|
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
data class CreatorRankingItem(
|
||||||
|
val creatorId: Long,
|
||||||
|
val rank: Int,
|
||||||
|
val previousRank: Int?,
|
||||||
|
val rankChangeType: CreatorRankingChangeType,
|
||||||
|
val rankChangeAmount: Int,
|
||||||
|
val creatorName: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
val isBlocked: Boolean
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(rank >= 1) { "rank must be greater than or equal to 1." }
|
||||||
|
}
|
||||||
|
|
||||||
|
val isInaccessible: Boolean = isBlocked
|
||||||
|
|
||||||
|
val isTouchable: Boolean = !isBlocked
|
||||||
|
|
||||||
|
fun displayName(inaccessibleMessage: String): String {
|
||||||
|
if (!isInaccessible) return creatorName
|
||||||
|
return if (rank <= 10) "" else inaccessibleMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Outline
|
||||||
|
import android.graphics.RenderEffect
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewOutlineProvider
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class CreatorRankingLargeCardView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private var image: ImageView? = null
|
||||||
|
private var dimGradient: View? = null
|
||||||
|
private var deltaGroup: View? = null
|
||||||
|
private var rankText: TextView? = null
|
||||||
|
private var deltaAmountText: TextView? = null
|
||||||
|
private var deltaIcon: ImageView? = null
|
||||||
|
private var nameText: TextView? = null
|
||||||
|
private var currentItem: CreatorRankingItem? = null
|
||||||
|
private var clickListener: ((CreatorRankingItem) -> Unit)? = null
|
||||||
|
|
||||||
|
override fun onFinishInflate() {
|
||||||
|
super.onFinishInflate()
|
||||||
|
image = findViewById(R.id.iv_creator_ranking_image)
|
||||||
|
dimGradient = findViewById(R.id.v_creator_ranking_dim_gradient)
|
||||||
|
deltaGroup = findViewById(R.id.ll_creator_ranking_delta)
|
||||||
|
rankText = findViewById(R.id.tv_creator_ranking_rank)
|
||||||
|
deltaAmountText = findViewById(R.id.tv_creator_ranking_delta_amount)
|
||||||
|
deltaIcon = findViewById(R.id.iv_creator_ranking_delta_icon)
|
||||||
|
nameText = findViewById(R.id.tv_creator_ranking_name)
|
||||||
|
clipToOutline = true
|
||||||
|
outlineProvider = roundOutlineProvider()
|
||||||
|
imageView().outlineProvider = roundOutlineProvider()
|
||||||
|
imageView().clipToOutline = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(item: CreatorRankingItem) {
|
||||||
|
currentItem = item
|
||||||
|
requireNotNull(rankText).apply {
|
||||||
|
text = item.rank.toString()
|
||||||
|
applyCreatorRankingRankGradient()
|
||||||
|
}
|
||||||
|
bindDelta(item)
|
||||||
|
requireNotNull(nameText).apply {
|
||||||
|
text = item.displayName(context.getString(R.string.creator_ranking_inaccessible_info))
|
||||||
|
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
|
||||||
|
}
|
||||||
|
dimGradient?.visibility = View.VISIBLE
|
||||||
|
applyAccessState(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCardSize(size: CreatorRankingCardSize) {
|
||||||
|
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
|
||||||
|
width = size.widthPx
|
||||||
|
height = size.heightPx
|
||||||
|
}
|
||||||
|
positionViews(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun imageView(): ImageView = requireNotNull(image)
|
||||||
|
|
||||||
|
fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?) {
|
||||||
|
clickListener = listener
|
||||||
|
currentItem?.let(::applyAccessState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindDelta(item: CreatorRankingItem) {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
|
||||||
|
applyDeltaContainer(presentation)
|
||||||
|
requireNotNull(deltaIcon).apply {
|
||||||
|
setImageResource(presentation.iconRes)
|
||||||
|
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||||
|
width = presentation.iconWidthDp.dpToPx()
|
||||||
|
height = presentation.iconHeightDp.dpToPx()
|
||||||
|
marginStart = presentation.iconMarginStartDp.dpToPx()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requireNotNull(deltaAmountText).apply {
|
||||||
|
text = presentation.amountText.orEmpty()
|
||||||
|
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDeltaContainer(presentation: CreatorRankingDeltaPresentation) {
|
||||||
|
requireNotNull(deltaGroup).apply {
|
||||||
|
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
|
||||||
|
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
|
||||||
|
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyAccessState(item: CreatorRankingItem) {
|
||||||
|
applyBlur(item.isInaccessible)
|
||||||
|
isClickable = item.isTouchable && clickListener != null
|
||||||
|
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun positionViews(size: CreatorRankingCardSize) {
|
||||||
|
val scale = size.widthPx / FIGMA_SIZE.toFloat()
|
||||||
|
requireNotNull(rankText).layoutParams = LayoutParams((112 * scale).roundToInt(), (124 * scale).roundToInt()).apply {
|
||||||
|
leftMargin = 0
|
||||||
|
topMargin = 0
|
||||||
|
}
|
||||||
|
requireNotNull(rankText).applyCreatorRankingRankGradient()
|
||||||
|
requireNotNull(nameText).layoutParams = LayoutParams((334 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
leftMargin = ((size.widthPx - (334 * scale)) / 2f).roundToInt()
|
||||||
|
topMargin = (305 * scale).roundToInt()
|
||||||
|
}
|
||||||
|
findViewById<View>(R.id.ll_creator_ranking_delta).layoutParams = LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply {
|
||||||
|
leftMargin = (20 * scale).roundToInt()
|
||||||
|
topMargin = (122 * scale).roundToInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyBlur(enabled: Boolean) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
imageView().setRenderEffect(
|
||||||
|
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
|
||||||
|
override fun getOutline(view: View, outline: Outline) {
|
||||||
|
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FIGMA_SIZE = 374
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
object CreatorRankingLayoutCalculator {
|
||||||
|
private const val HORIZONTAL_FIGMA_WIDTH = 374
|
||||||
|
private const val HORIZONTAL_FIGMA_HEIGHT = 100
|
||||||
|
|
||||||
|
fun calculate(
|
||||||
|
parentWidthPx: Int,
|
||||||
|
parentHorizontalPaddingPx: Int = 0,
|
||||||
|
horizontalGapPx: Int,
|
||||||
|
placement: CreatorRankingPlacement
|
||||||
|
): CreatorRankingCardSize {
|
||||||
|
require(parentWidthPx > 0) { "parentWidthPx must be > 0." }
|
||||||
|
require(parentHorizontalPaddingPx >= 0) { "parentHorizontalPaddingPx must be >= 0." }
|
||||||
|
require(horizontalGapPx >= 0) { "horizontalGapPx must be >= 0." }
|
||||||
|
require(placement.itemsPerRow > 0) { "itemsPerRow must be > 0." }
|
||||||
|
|
||||||
|
val totalGap = horizontalGapPx * (placement.itemsPerRow - 1)
|
||||||
|
val availableWidth = parentWidthPx - parentHorizontalPaddingPx
|
||||||
|
require(availableWidth > 0) { "available width must be > 0." }
|
||||||
|
val width = (availableWidth - totalGap) / placement.itemsPerRow
|
||||||
|
val height = when (placement.variant) {
|
||||||
|
CreatorRankingCardVariant.Large,
|
||||||
|
CreatorRankingCardVariant.Compact -> width
|
||||||
|
CreatorRankingCardVariant.Horizontal -> (width * HORIZONTAL_FIGMA_HEIGHT) / HORIZONTAL_FIGMA_WIDTH
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreatorRankingCardSize(widthPx = width, heightPx = height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorRankingCardSize(
|
||||||
|
val widthPx: Int,
|
||||||
|
val heightPx: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
data class CreatorRankingPlacement(
|
||||||
|
val variant: CreatorRankingCardVariant,
|
||||||
|
val itemsPerRow: Int
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromRank(rank: Int): CreatorRankingPlacement {
|
||||||
|
require(rank >= 1) { "rank must be greater than or equal to 1." }
|
||||||
|
return when (rank) {
|
||||||
|
1 -> CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
|
||||||
|
in 2..7 -> CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 2)
|
||||||
|
in 8..10 -> CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 3)
|
||||||
|
else -> CreatorRankingPlacement(CreatorRankingCardVariant.Horizontal, itemsPerRow = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.LinearGradient
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.widget.TextView
|
||||||
|
|
||||||
|
internal fun TextView.applyCreatorRankingRankGradient() {
|
||||||
|
post {
|
||||||
|
val gradientHeight = height.takeIf { it > 0 } ?: lineHeight
|
||||||
|
paint.shader = LinearGradient(
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
0f,
|
||||||
|
gradientHeight.toFloat(),
|
||||||
|
Color.WHITE,
|
||||||
|
Color.parseColor("#EEEEEE"),
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_decrease.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_decrease.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 231 B |
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_increase.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_increase.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 B |
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_stay.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_rank_caret_stay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 B |
BIN
app/src/main/res/drawable-mdpi/ic_rank_new.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_rank_new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 802 B |
5
app/src/main/res/drawable/bg_creator_ranking_delta.xml
Normal file
5
app/src/main/res/drawable/bg_creator_ranking_delta.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="@color/gray_900" />
|
||||||
|
<corners android:radius="4dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:angle="270"
|
||||||
|
android:startColor="#00000000"
|
||||||
|
android:centerColor="#00000000"
|
||||||
|
android:centerY="0.64"
|
||||||
|
android:endColor="#80000000" />
|
||||||
|
<corners android:radius="@dimen/radius_14" />
|
||||||
|
</shape>
|
||||||
5
app/src/main/res/drawable/bg_creator_ranking_image.xml
Normal file
5
app/src/main/res/drawable/bg_creator_ranking_image.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="@color/gray_900" />
|
||||||
|
<corners android:radius="@dimen/radius_14" />
|
||||||
|
</shape>
|
||||||
BIN
app/src/main/res/font/pattaya_regular.ttf
Normal file
BIN
app/src/main/res/font/pattaya_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/phosphate_solid.ttf
Normal file
BIN
app/src/main/res/font/phosphate_solid.ttf
Normal file
Binary file not shown.
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingCompactCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iv_creator_ranking_image"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/bg_creator_ranking_image"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:scaleType="centerCrop" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/v_creator_ranking_dim_gradient"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/bg_creator_ranking_dim_gradient" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_ranking_rank"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="@font/pattaya_regular"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:shadowColor="#7A000000"
|
||||||
|
android:shadowRadius="4"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="54sp"
|
||||||
|
tools:text="2" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/ll_creator_ranking_delta"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_creator_ranking_delta"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="4dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_ranking_delta_amount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="@font/medium"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="16sp"
|
||||||
|
tools:text="4" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iv_creator_ranking_delta_icon"
|
||||||
|
android:layout_width="14dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_marginStart="2dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
tools:src="@drawable/ic_rank_caret_increase" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_ranking_name"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:fontFamily="@font/bold"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="22sp"
|
||||||
|
tools:text="크리에이터 이름" />
|
||||||
|
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingCompactCardView>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingHorizontalCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/ll_creator_ranking_rank_group"
|
||||||
|
android:layout_width="49dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_ranking_rank"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="@font/pattaya_regular"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:shadowColor="#7A000000"
|
||||||
|
android:shadowRadius="4"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="40sp"
|
||||||
|
tools:text="11" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/ll_creator_ranking_delta"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_creator_ranking_delta"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="4dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_ranking_delta_amount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="@font/medium"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="16sp"
|
||||||
|
tools:text="4" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iv_creator_ranking_delta_icon"
|
||||||
|
android:layout_width="14dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_marginStart="2dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
tools:src="@drawable/ic_rank_caret_increase" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iv_creator_ranking_image"
|
||||||
|
android:layout_width="80dp"
|
||||||
|
android:layout_height="80dp"
|
||||||
|
android:background="@drawable/bg_creator_ranking_image"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:scaleType="centerCrop" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_ranking_name"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:fontFamily="@font/bold"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="18sp"
|
||||||
|
tools:text="크리에이터 이름" />
|
||||||
|
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingHorizontalCardView>
|
||||||
73
app/src/main/res/layout/view_creator_ranking_large_card.xml
Normal file
73
app/src/main/res/layout/view_creator_ranking_large_card.xml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLargeCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iv_creator_ranking_image"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/bg_creator_ranking_image"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:scaleType="centerCrop" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/v_creator_ranking_dim_gradient"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/bg_creator_ranking_dim_gradient" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_ranking_rank"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="@font/pattaya_regular"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:shadowColor="#7A000000"
|
||||||
|
android:shadowRadius="4"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="96sp"
|
||||||
|
tools:text="1" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/ll_creator_ranking_delta"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_creator_ranking_delta"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="4dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_ranking_delta_amount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="@font/medium"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="16sp"
|
||||||
|
tools:text="4" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/iv_creator_ranking_delta_icon"
|
||||||
|
android:layout_width="14dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_marginStart="2dp"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
tools:src="@drawable/ic_rank_caret_increase" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_creator_ranking_name"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:fontFamily="@font/bold"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="32sp"
|
||||||
|
tools:text="크리에이터 이름" />
|
||||||
|
</kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLargeCardView>
|
||||||
@@ -1308,6 +1308,7 @@ The upload will continue even if you leave this page.</string>
|
|||||||
<string name="audio_content_content_new_title">New shorts</string>
|
<string name="audio_content_content_new_title">New shorts</string>
|
||||||
<string name="audio_content_content_tag_recommend_title">Recommended content by tag</string>
|
<string name="audio_content_content_tag_recommend_title">Recommended content by tag</string>
|
||||||
<string name="audio_content_creator_rank_title">Trending creators</string>
|
<string name="audio_content_creator_rank_title">Trending creators</string>
|
||||||
|
<string name="creator_ranking_inaccessible_info">This information is not accessible.</string>
|
||||||
<string name="audio_content_detail_age_badge_19">19</string>
|
<string name="audio_content_detail_age_badge_19">19</string>
|
||||||
<string name="audio_content_free_channel_recommend_title">Recommended free content by channel</string>
|
<string name="audio_content_free_channel_recommend_title">Recommended free content by channel</string>
|
||||||
<string name="audio_content_free_creator_intro_title">Creator intro</string>
|
<string name="audio_content_free_creator_intro_title">Creator intro</string>
|
||||||
|
|||||||
@@ -1306,6 +1306,7 @@
|
|||||||
<string name="audio_content_content_new_title">新しい短編</string>
|
<string name="audio_content_content_new_title">新しい短編</string>
|
||||||
<string name="audio_content_content_tag_recommend_title">タグ別おすすめコンテンツ</string>
|
<string name="audio_content_content_tag_recommend_title">タグ別おすすめコンテンツ</string>
|
||||||
<string name="audio_content_creator_rank_title">人気急上昇</string>
|
<string name="audio_content_creator_rank_title">人気急上昇</string>
|
||||||
|
<string name="creator_ranking_inaccessible_info">アクセスできない情報です。</string>
|
||||||
<string name="audio_content_detail_age_badge_19">R-18</string>
|
<string name="audio_content_detail_age_badge_19">R-18</string>
|
||||||
<string name="audio_content_free_channel_recommend_title">チャンネル別おすすめ無料コンテンツ</string>
|
<string name="audio_content_free_channel_recommend_title">チャンネル別おすすめ無料コンテンツ</string>
|
||||||
<string name="audio_content_free_creator_intro_title">クリエイター紹介</string>
|
<string name="audio_content_free_creator_intro_title">クリエイター紹介</string>
|
||||||
|
|||||||
@@ -1323,6 +1323,7 @@
|
|||||||
<string name="audio_content_short_play_title">숏플</string>
|
<string name="audio_content_short_play_title">숏플</string>
|
||||||
<string name="audio_content_new_content_title">새로운 콘텐츠</string>
|
<string name="audio_content_new_content_title">새로운 콘텐츠</string>
|
||||||
<string name="audio_content_main_popular_notice">※ 인기 순위는 매주 업데이트됩니다.</string>
|
<string name="audio_content_main_popular_notice">※ 인기 순위는 매주 업데이트됩니다.</string>
|
||||||
|
<string name="creator_ranking_inaccessible_info">접근할 수 없는 정보입니다.</string>
|
||||||
|
|
||||||
<!-- Audio content tabs -->
|
<!-- Audio content tabs -->
|
||||||
<string name="audio_content_alarm_new_title">새로운 알람</string>
|
<string name="audio_content_alarm_new_title">새로운 알람</string>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreatorRankingAdapterLayoutTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `span count supports full two and three column ranking rows`() {
|
||||||
|
assertEquals(6, CreatorRankingAdapter.GRID_SPAN_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `span lookup maps rank groups to expected row widths`() {
|
||||||
|
val spanLookup = CreatorRankingAdapter.createSpanSizeLookup()
|
||||||
|
|
||||||
|
assertEquals(6, spanLookup.getSpanSize(0))
|
||||||
|
(1..6).forEach { position ->
|
||||||
|
assertEquals(3, spanLookup.getSpanSize(position))
|
||||||
|
}
|
||||||
|
(7..9).forEach { position ->
|
||||||
|
assertEquals(2, spanLookup.getSpanSize(position))
|
||||||
|
}
|
||||||
|
listOf(10, 11, 20).forEach { position ->
|
||||||
|
assertEquals(6, spanLookup.getSpanSize(position))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreatorRankingBlurTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocked image uses coil blur fallback before Android 12`() {
|
||||||
|
assertTrue(CreatorRankingBlur.shouldUseCoilBlur(enabled = true, sdkInt = Build.VERSION_CODES.R))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocked image keeps render effect path on Android 12 or later`() {
|
||||||
|
assertFalse(CreatorRankingBlur.shouldUseCoilBlur(enabled = true, sdkInt = Build.VERSION_CODES.S))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accessible image does not use coil blur fallback`() {
|
||||||
|
assertFalse(CreatorRankingBlur.shouldUseCoilBlur(enabled = false, sdkInt = Build.VERSION_CODES.R))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreatorRankingDeltaPresentationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `increase shows caret and amount`() {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Increase, amount = 4)
|
||||||
|
|
||||||
|
assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes)
|
||||||
|
assertTrue(presentation.showAmount)
|
||||||
|
assertEquals("4", presentation.amountText)
|
||||||
|
assertTrue(presentation.showPillBackground)
|
||||||
|
assertEquals(14, presentation.iconWidthDp)
|
||||||
|
assertEquals(14, presentation.iconHeightDp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrease shows caret and amount`() {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Decrease, amount = 4)
|
||||||
|
|
||||||
|
assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes)
|
||||||
|
assertTrue(presentation.showAmount)
|
||||||
|
assertEquals("4", presentation.amountText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stay shows stay icon without amount`() {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Stay, amount = 0)
|
||||||
|
|
||||||
|
assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes)
|
||||||
|
assertFalse(presentation.showAmount)
|
||||||
|
assertNull(presentation.amountText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new shows new image without amount`() {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.New, amount = 0)
|
||||||
|
|
||||||
|
assertEquals(R.drawable.ic_rank_new, presentation.iconRes)
|
||||||
|
assertFalse(presentation.showAmount)
|
||||||
|
assertNull(presentation.amountText)
|
||||||
|
assertFalse(presentation.showPillBackground)
|
||||||
|
assertEquals(36, presentation.iconWidthDp)
|
||||||
|
assertEquals(23, presentation.iconHeightDp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreatorRankingItemTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocked item is inaccessible`() {
|
||||||
|
val item = sampleItem(isBlocked = true)
|
||||||
|
|
||||||
|
assertTrue(item.isInaccessible)
|
||||||
|
assertFalse(item.isTouchable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accessible item is touchable`() {
|
||||||
|
val item = sampleItem()
|
||||||
|
|
||||||
|
assertFalse(item.isInaccessible)
|
||||||
|
assertTrue(item.isTouchable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `top ten blocked item hides creator name`() {
|
||||||
|
val item = sampleItem(rank = 10, isBlocked = true)
|
||||||
|
|
||||||
|
assertEquals("", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rank 11 blocked item shows inaccessible message`() {
|
||||||
|
val item = sampleItem(rank = 11, isBlocked = true)
|
||||||
|
|
||||||
|
assertEquals("접근할 수 없는 정보입니다.", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accessible item shows creator name`() {
|
||||||
|
val item = sampleItem(creatorName = "크리에이터 이름")
|
||||||
|
|
||||||
|
assertEquals("크리에이터 이름", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleItem(
|
||||||
|
creatorId: Long = 1L,
|
||||||
|
rank: Int = 1,
|
||||||
|
previousRank: Int? = 5,
|
||||||
|
rankChangeType: CreatorRankingChangeType = CreatorRankingChangeType.Increase,
|
||||||
|
rankChangeAmount: Int = 4,
|
||||||
|
creatorName: String = "크리에이터 이름",
|
||||||
|
imageUrl: String = "https://example.com/image.png",
|
||||||
|
isBlocked: Boolean = false
|
||||||
|
) = CreatorRankingItem(
|
||||||
|
creatorId = creatorId,
|
||||||
|
rank = rank,
|
||||||
|
previousRank = previousRank,
|
||||||
|
rankChangeType = rankChangeType,
|
||||||
|
rankChangeAmount = rankChangeAmount,
|
||||||
|
creatorName = creatorName,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
isBlocked = isBlocked
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreatorRankingLayoutCalculatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `large card fills available width as square`() {
|
||||||
|
val size = CreatorRankingLayoutCalculator.calculate(
|
||||||
|
parentWidthPx = 374,
|
||||||
|
horizontalGapPx = 4,
|
||||||
|
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(374, size.widthPx)
|
||||||
|
assertEquals(374, size.heightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `large card excludes parent horizontal padding from available width`() {
|
||||||
|
val size = CreatorRankingLayoutCalculator.calculate(
|
||||||
|
parentWidthPx = 374,
|
||||||
|
parentHorizontalPaddingPx = 40,
|
||||||
|
horizontalGapPx = 4,
|
||||||
|
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(334, size.widthPx)
|
||||||
|
assertEquals(334, size.heightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compact card can use two columns`() {
|
||||||
|
val size = CreatorRankingLayoutCalculator.calculate(
|
||||||
|
parentWidthPx = 374,
|
||||||
|
horizontalGapPx = 4,
|
||||||
|
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(185, size.widthPx)
|
||||||
|
assertEquals(185, size.heightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compact card can use three columns`() {
|
||||||
|
val size = CreatorRankingLayoutCalculator.calculate(
|
||||||
|
parentWidthPx = 374,
|
||||||
|
horizontalGapPx = 4,
|
||||||
|
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(122, size.widthPx)
|
||||||
|
assertEquals(122, size.heightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `horizontal card keeps figma aspect ratio`() {
|
||||||
|
val size = CreatorRankingLayoutCalculator.calculate(
|
||||||
|
parentWidthPx = 374,
|
||||||
|
horizontalGapPx = 4,
|
||||||
|
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Horizontal, itemsPerRow = 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(374, size.widthPx)
|
||||||
|
assertEquals(100, size.heightPx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreatorRankingPlacementTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rank 1 uses large variant and one item row`() {
|
||||||
|
val placement = CreatorRankingPlacement.fromRank(1)
|
||||||
|
|
||||||
|
assertEquals(CreatorRankingCardVariant.Large, placement.variant)
|
||||||
|
assertEquals(1, placement.itemsPerRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rank 2 to 7 uses compact variant and two item row`() {
|
||||||
|
(2..7).forEach { rank ->
|
||||||
|
val placement = CreatorRankingPlacement.fromRank(rank)
|
||||||
|
|
||||||
|
assertEquals(CreatorRankingCardVariant.Compact, placement.variant)
|
||||||
|
assertEquals(2, placement.itemsPerRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rank 8 to 10 uses compact variant and three item row`() {
|
||||||
|
(8..10).forEach { rank ->
|
||||||
|
val placement = CreatorRankingPlacement.fromRank(rank)
|
||||||
|
|
||||||
|
assertEquals(CreatorRankingCardVariant.Compact, placement.variant)
|
||||||
|
assertEquals(3, placement.itemsPerRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rank 11 or greater uses horizontal variant and one item row`() {
|
||||||
|
listOf(11, 12, 100).forEach { rank ->
|
||||||
|
val placement = CreatorRankingPlacement.fromRank(rank)
|
||||||
|
|
||||||
|
assertEquals(CreatorRankingCardVariant.Horizontal, placement.variant)
|
||||||
|
assertEquals(1, placement.itemsPerRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::class)
|
||||||
|
fun `rank less than 1 is invalid`() {
|
||||||
|
CreatorRankingPlacement.fromRank(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
800
docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md
Normal file
800
docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md
Normal file
@@ -0,0 +1,800 @@
|
|||||||
|
# 크리에이터 랭킹 위젯 컴포넌트 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Figma `20:3702`, `20:3709`, `20:3711`, `20:3713` 기준으로 순위 구간별 크리에이터 랭킹 위젯 컴포넌트를 추가한다.
|
||||||
|
|
||||||
|
**Architecture:** 랭킹 항목의 순위/변동/차단 관계 상태를 순수 Kotlin contract로 먼저 분리하고, Android custom view와 RecyclerView adapter가 이 contract를 바인딩한다. 카드 UI는 `Large`, `Compact`, `Horizontal` 3개 variant로 나누며, `Compact`는 2위~10위가 공유하고 실제 크기는 row count와 부모 폭으로 계산한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Android XML Views, Kotlin custom View, RecyclerView, ViewBinding/resource merge, JUnit4 local unit test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 목표
|
||||||
|
- 1위는 `Large` 전용 카드로 구현한다.
|
||||||
|
- 2위~10위는 동일한 `Compact` 카드 UI로 구현하고, 2위~7위는 한 줄 2개, 8위~10위는 한 줄 3개로 배치한다.
|
||||||
|
- 11위 이후는 `Horizontal` 카드로 구현한다.
|
||||||
|
- rank delta 상태에 따라 상승/하락/동일/신규 진입 UI를 표시한다.
|
||||||
|
- 차단 관계인 크리에이터는 이미지 블러, 이름 비노출 또는 대체문구, 터치 불가 상태로 표시한다.
|
||||||
|
- 이미지 크기는 고정하지 않고 row container 폭과 row count로 계산한다.
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt`
|
||||||
|
- 랭킹 UI에 필요한 순수 데이터 모델과 차단 관계 상태 계산을 정의한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.kt`
|
||||||
|
- `Increase`, `Decrease`, `Stay`, `New` 순위 변동 타입을 정의한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCardVariant.kt`
|
||||||
|
- `Large`, `Compact`, `Horizontal` 카드 UI variant를 정의한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacement.kt`
|
||||||
|
- rank 기준 variant와 row count를 함께 결정한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculator.kt`
|
||||||
|
- 부모 폭, horizontal gap, row count 기준으로 item width/height를 계산한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt`
|
||||||
|
- 순위 변동 타입별 아이콘과 숫자 표시 여부를 정의한다. `Stay`와 `New`는 숫자를 표시하지 않는다.
|
||||||
|
- Create: `app/src/main/res/layout/view_creator_ranking_large_card.xml`
|
||||||
|
- 1위 전용 큰 정사각형 카드 layout을 정의한다.
|
||||||
|
- Create: `app/src/main/res/layout/view_creator_ranking_compact_card.xml`
|
||||||
|
- 2위~10위 공통 정사각형 카드 layout을 정의한다.
|
||||||
|
- Create: `app/src/main/res/layout/view_creator_ranking_horizontal_card.xml`
|
||||||
|
- 11위 이후 가로형 카드 layout을 정의한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLargeCardView.kt`
|
||||||
|
- 1위 전용 rank, delta, name, access state를 바인딩한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCompactCardView.kt`
|
||||||
|
- 2위~10위 공통 rank, delta, name, access state를 바인딩한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingHorizontalCardView.kt`
|
||||||
|
- 11위 이후 가로형 카드의 rank, delta, name, access state를 바인딩한다.
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt`
|
||||||
|
- rank별 viewType과 터치 가능 여부를 처리한다.
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacementTest.kt`
|
||||||
|
- rank별 variant/row count 계약을 검증한다.
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItemTest.kt`
|
||||||
|
- 차단 관계 상태와 표시 이름 정책을 검증한다.
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculatorTest.kt`
|
||||||
|
- 부모 폭 기반 크기 계산을 검증한다.
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentationTest.kt`
|
||||||
|
- 상승/하락/동일/신규 진입 표시 계약을 검증한다.
|
||||||
|
- Modify: `app/src/main/res/values/strings.xml`
|
||||||
|
- 접근 불가 대체문구 `접근할 수 없는 정보입니다.`를 추가한다.
|
||||||
|
- Modify: `app/src/main/res/values-en/strings.xml`, `app/src/main/res/values-ja/strings.xml`
|
||||||
|
- 기존 다국어 정책에 맞춰 접근 불가 대체문구를 추가한다.
|
||||||
|
- Add if missing: `app/src/main/res/drawable/ic_rank_caret_increase.xml`, `ic_rank_caret_decrease.xml`, `ic_rank_caret_stay.xml`, `ic_rank_new.xml`
|
||||||
|
- Figma 에셋이 프로젝트에 없으면 디자인 에셋을 추가한다.
|
||||||
|
- Modify: `docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md`
|
||||||
|
- 구현 중 체크박스와 검증 기록을 누적한다.
|
||||||
|
|
||||||
|
## 구현 계획
|
||||||
|
|
||||||
|
### Task 1: 기존 리소스 및 유사 UI 확인
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `app/src/main/java/kr/co/vividnext/sodalive/home/CreatorRankingAdapter.kt`
|
||||||
|
- Read: `app/src/main/res/layout/item_home_creator.xml`
|
||||||
|
- Read: `app/src/main/java/kr/co/vividnext/sodalive/common/image/BlurTransformation.kt`
|
||||||
|
- Read: `app/src/main/res/values/colors.xml`
|
||||||
|
- Read: `app/src/main/res/values/dimens.xml`
|
||||||
|
- Read: `app/src/main/res/values/typography.xml`
|
||||||
|
|
||||||
|
- [x] **Step 1: 현재 랭킹/블러/에셋 사용처 확인**
|
||||||
|
|
||||||
|
Run: `rg -n "CreatorRankingAdapter|ic_rank_caret|ic_rank_new|BlurTransformation|img_rank_" app/src/main app/src/test docs`
|
||||||
|
|
||||||
|
Expected: 기존 홈 크리에이터 랭킹과 blur 구현, 기존 rank 이미지 리소스 사용처를 확인한다.
|
||||||
|
|
||||||
|
- [x] **Step 2: Figma 세부 컨텍스트 재확인**
|
||||||
|
|
||||||
|
Run tools:
|
||||||
|
- `Figma_get_design_context(20:3702)`
|
||||||
|
- `Figma_get_design_context(20:3709)`
|
||||||
|
- `Figma_get_design_context(20:3711)`
|
||||||
|
- `Figma_get_design_context(20:3713)`
|
||||||
|
|
||||||
|
Expected: typography, color, radius, spacing, icon asset name을 확인한다. 도구가 다시 timeout이면 현재 PRD의 screenshot 기준으로 진행하고 검증 기록에 남긴다.
|
||||||
|
|
||||||
|
- [x] **Step 3: Figma token을 구현 기준으로 정리**
|
||||||
|
|
||||||
|
Expected token contract:
|
||||||
|
- 공통 카드 image radius: `radius_14` 또는 `14dp`
|
||||||
|
- 공통 dim gradient: top transparent, bottom black, opacity `50%`, transition start `64.423%`
|
||||||
|
- 공통 `rank-num`: background `gray_900` (`#202020`), radius `4dp`, horizontal padding `4dp`, gap `2dp`
|
||||||
|
- 공통 `rank-num` 숫자: Pretendard Variable Medium, `16sp`, line-height `1.45`, white
|
||||||
|
- 공통 caret icon: `14dp x 14dp`
|
||||||
|
- 순위 숫자: Pattaya Regular, white~`#EEEEEE` gradient, `0px 0px 4px rgba(0,0,0,0.48)` shadow
|
||||||
|
- `Large` creator name: Pretendard Variable Bold, `32sp`, line-height `1.45`, white
|
||||||
|
- 2열 `Compact` creator name: Pretendard Variable Bold, `22sp`, line-height `1.45`, white
|
||||||
|
- 3열 `Compact` creator name: Pretendard Variable Bold, `14sp`, line-height normal, white
|
||||||
|
- `Horizontal` creator name: Pretendard Variable Bold, `18sp`, line-height `1.45`, white
|
||||||
|
|
||||||
|
### Task 2: Rank placement contract TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacementTest.kt`
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCardVariant.kt`
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacement.kt`
|
||||||
|
|
||||||
|
- [x] **Step 1: RED - rank별 variant와 row count 테스트 추가**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreatorRankingPlacementTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rank 1 uses large variant and one item row`() {
|
||||||
|
val placement = CreatorRankingPlacement.fromRank(1)
|
||||||
|
|
||||||
|
assertEquals(CreatorRankingCardVariant.Large, placement.variant)
|
||||||
|
assertEquals(1, placement.itemsPerRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rank 2 to 7 uses compact variant and two item row`() {
|
||||||
|
(2..7).forEach { rank ->
|
||||||
|
val placement = CreatorRankingPlacement.fromRank(rank)
|
||||||
|
|
||||||
|
assertEquals(CreatorRankingCardVariant.Compact, placement.variant)
|
||||||
|
assertEquals(2, placement.itemsPerRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rank 8 to 10 uses compact variant and three item row`() {
|
||||||
|
(8..10).forEach { rank ->
|
||||||
|
val placement = CreatorRankingPlacement.fromRank(rank)
|
||||||
|
|
||||||
|
assertEquals(CreatorRankingCardVariant.Compact, placement.variant)
|
||||||
|
assertEquals(3, placement.itemsPerRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rank 11 or greater uses horizontal variant and one item row`() {
|
||||||
|
listOf(11, 12, 100).forEach { rank ->
|
||||||
|
val placement = CreatorRankingPlacement.fromRank(rank)
|
||||||
|
|
||||||
|
assertEquals(CreatorRankingCardVariant.Horizontal, placement.variant)
|
||||||
|
assertEquals(1, placement.itemsPerRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::class)
|
||||||
|
fun `rank less than 1 is invalid`() {
|
||||||
|
CreatorRankingPlacement.fromRank(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: RED 실행**
|
||||||
|
|
||||||
|
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingPlacementTest"`
|
||||||
|
|
||||||
|
Expected: `Unresolved reference 'CreatorRankingPlacement'`로 실패한다.
|
||||||
|
|
||||||
|
- [x] **Step 3: GREEN - 최소 placement contract 추가**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
enum class CreatorRankingCardVariant {
|
||||||
|
Large,
|
||||||
|
Compact,
|
||||||
|
Horizontal
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
data class CreatorRankingPlacement(
|
||||||
|
val variant: CreatorRankingCardVariant,
|
||||||
|
val itemsPerRow: Int
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromRank(rank: Int): CreatorRankingPlacement {
|
||||||
|
require(rank >= 1) { "rank must be greater than or equal to 1." }
|
||||||
|
return when (rank) {
|
||||||
|
1 -> CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
|
||||||
|
in 2..7 -> CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 2)
|
||||||
|
in 8..10 -> CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 3)
|
||||||
|
else -> CreatorRankingPlacement(CreatorRankingCardVariant.Horizontal, itemsPerRow = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: GREEN 실행**
|
||||||
|
|
||||||
|
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingPlacementTest"`
|
||||||
|
|
||||||
|
Expected: `BUILD SUCCESSFUL`
|
||||||
|
|
||||||
|
### Task 3: Ranking item state contract TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItemTest.kt`
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.kt`
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt`
|
||||||
|
|
||||||
|
- [x] **Step 1: RED - 접근 가능/불가 표시 정책 테스트 추가**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreatorRankingItemTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blocked item is inaccessible`() {
|
||||||
|
val item = sampleItem(isBlocked = true)
|
||||||
|
|
||||||
|
assertTrue(item.isInaccessible)
|
||||||
|
assertFalse(item.isTouchable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accessible item is touchable`() {
|
||||||
|
val item = sampleItem()
|
||||||
|
|
||||||
|
assertFalse(item.isInaccessible)
|
||||||
|
assertTrue(item.isTouchable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `top ten blocked item hides creator name`() {
|
||||||
|
val item = sampleItem(rank = 10, isBlocked = true)
|
||||||
|
|
||||||
|
assertEquals("", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rank 11 blocked item shows inaccessible message`() {
|
||||||
|
val item = sampleItem(rank = 11, isBlocked = true)
|
||||||
|
|
||||||
|
assertEquals("접근할 수 없는 정보입니다.", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `accessible item shows creator name`() {
|
||||||
|
val item = sampleItem(creatorName = "크리에이터 이름")
|
||||||
|
|
||||||
|
assertEquals("크리에이터 이름", item.displayName(inaccessibleMessage = "접근할 수 없는 정보입니다."))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sampleItem(
|
||||||
|
creatorId: Long = 1L,
|
||||||
|
rank: Int = 1,
|
||||||
|
previousRank: Int? = 5,
|
||||||
|
rankChangeType: CreatorRankingChangeType = CreatorRankingChangeType.Increase,
|
||||||
|
rankChangeAmount: Int = 4,
|
||||||
|
creatorName: String = "크리에이터 이름",
|
||||||
|
imageUrl: String = "https://example.com/image.png",
|
||||||
|
isBlocked: Boolean = false
|
||||||
|
) = CreatorRankingItem(
|
||||||
|
creatorId = creatorId,
|
||||||
|
rank = rank,
|
||||||
|
previousRank = previousRank,
|
||||||
|
rankChangeType = rankChangeType,
|
||||||
|
rankChangeAmount = rankChangeAmount,
|
||||||
|
creatorName = creatorName,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
isBlocked = isBlocked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: RED 실행**
|
||||||
|
|
||||||
|
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingItemTest"`
|
||||||
|
|
||||||
|
Expected: `Unresolved reference 'CreatorRankingItem'`로 실패한다.
|
||||||
|
|
||||||
|
- [x] **Step 3: GREEN - 순수 상태 모델 추가**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
enum class CreatorRankingChangeType {
|
||||||
|
Increase,
|
||||||
|
Decrease,
|
||||||
|
Stay,
|
||||||
|
New
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
data class CreatorRankingItem(
|
||||||
|
val creatorId: Long,
|
||||||
|
val rank: Int,
|
||||||
|
val previousRank: Int?,
|
||||||
|
val rankChangeType: CreatorRankingChangeType,
|
||||||
|
val rankChangeAmount: Int,
|
||||||
|
val creatorName: String,
|
||||||
|
val imageUrl: String,
|
||||||
|
val isBlocked: Boolean
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(rank >= 1) { "rank must be greater than or equal to 1." }
|
||||||
|
}
|
||||||
|
|
||||||
|
val isInaccessible: Boolean = isBlocked
|
||||||
|
|
||||||
|
val isTouchable: Boolean = !isBlocked
|
||||||
|
|
||||||
|
fun displayName(inaccessibleMessage: String): String {
|
||||||
|
if (!isInaccessible) return creatorName
|
||||||
|
return if (rank <= 10) "" else inaccessibleMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: GREEN 실행**
|
||||||
|
|
||||||
|
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingItemTest"`
|
||||||
|
|
||||||
|
Expected: `BUILD SUCCESSFUL`
|
||||||
|
|
||||||
|
### Task 4: Rank delta presentation TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentationTest.kt`
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt`
|
||||||
|
|
||||||
|
- [x] **Step 1: RED - 순위 변동 표시 정책 테스트 추가**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreatorRankingDeltaPresentationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `increase shows caret and amount`() {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Increase, amount = 4)
|
||||||
|
|
||||||
|
assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes)
|
||||||
|
assertTrue(presentation.showAmount)
|
||||||
|
assertEquals("4", presentation.amountText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrease shows caret and amount`() {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Decrease, amount = 4)
|
||||||
|
|
||||||
|
assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes)
|
||||||
|
assertTrue(presentation.showAmount)
|
||||||
|
assertEquals("4", presentation.amountText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stay shows stay icon without amount`() {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Stay, amount = 0)
|
||||||
|
|
||||||
|
assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes)
|
||||||
|
assertFalse(presentation.showAmount)
|
||||||
|
assertNull(presentation.amountText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new shows new image without amount`() {
|
||||||
|
val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.New, amount = 0)
|
||||||
|
|
||||||
|
assertEquals(R.drawable.ic_rank_new, presentation.iconRes)
|
||||||
|
assertFalse(presentation.showAmount)
|
||||||
|
assertNull(presentation.amountText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: RED 실행**
|
||||||
|
|
||||||
|
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingDeltaPresentationTest"`
|
||||||
|
|
||||||
|
Expected: `Unresolved reference 'CreatorRankingDeltaPresentation'`로 실패한다.
|
||||||
|
|
||||||
|
- [x] **Step 3: GREEN - delta presentation contract 추가**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import kr.co.vividnext.sodalive.R
|
||||||
|
|
||||||
|
data class CreatorRankingDeltaPresentation(
|
||||||
|
@DrawableRes val iconRes: Int,
|
||||||
|
val showAmount: Boolean,
|
||||||
|
val amountText: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(type: CreatorRankingChangeType, amount: Int): CreatorRankingDeltaPresentation = when (type) {
|
||||||
|
CreatorRankingChangeType.Increase -> CreatorRankingDeltaPresentation(
|
||||||
|
iconRes = R.drawable.ic_rank_caret_increase,
|
||||||
|
showAmount = true,
|
||||||
|
amountText = amount.toString()
|
||||||
|
)
|
||||||
|
CreatorRankingChangeType.Decrease -> CreatorRankingDeltaPresentation(
|
||||||
|
iconRes = R.drawable.ic_rank_caret_decrease,
|
||||||
|
showAmount = true,
|
||||||
|
amountText = amount.toString()
|
||||||
|
)
|
||||||
|
CreatorRankingChangeType.Stay -> CreatorRankingDeltaPresentation(
|
||||||
|
iconRes = R.drawable.ic_rank_caret_stay,
|
||||||
|
showAmount = false,
|
||||||
|
amountText = null
|
||||||
|
)
|
||||||
|
CreatorRankingChangeType.New -> CreatorRankingDeltaPresentation(
|
||||||
|
iconRes = R.drawable.ic_rank_new,
|
||||||
|
showAmount = false,
|
||||||
|
amountText = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: GREEN 실행**
|
||||||
|
|
||||||
|
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingDeltaPresentationTest"`
|
||||||
|
|
||||||
|
Expected: `BUILD SUCCESSFUL`
|
||||||
|
|
||||||
|
### Task 5: 부모 폭 기반 layout 계산 TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculatorTest.kt`
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLayoutCalculator.kt`
|
||||||
|
|
||||||
|
- [x] **Step 1: RED - 고정 이미지 크기 방지 계산 테스트 추가**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CreatorRankingLayoutCalculatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `large card fills available width as square`() {
|
||||||
|
val size = CreatorRankingLayoutCalculator.calculate(
|
||||||
|
parentWidthPx = 374,
|
||||||
|
horizontalGapPx = 4,
|
||||||
|
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Large, itemsPerRow = 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(374, size.widthPx)
|
||||||
|
assertEquals(374, size.heightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compact card can use two columns`() {
|
||||||
|
val size = CreatorRankingLayoutCalculator.calculate(
|
||||||
|
parentWidthPx = 374,
|
||||||
|
horizontalGapPx = 4,
|
||||||
|
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(185, size.widthPx)
|
||||||
|
assertEquals(185, size.heightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compact card can use three columns`() {
|
||||||
|
val size = CreatorRankingLayoutCalculator.calculate(
|
||||||
|
parentWidthPx = 374,
|
||||||
|
horizontalGapPx = 4,
|
||||||
|
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Compact, itemsPerRow = 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(122, size.widthPx)
|
||||||
|
assertEquals(122, size.heightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `horizontal card keeps figma aspect ratio`() {
|
||||||
|
val size = CreatorRankingLayoutCalculator.calculate(
|
||||||
|
parentWidthPx = 374,
|
||||||
|
horizontalGapPx = 4,
|
||||||
|
placement = CreatorRankingPlacement(CreatorRankingCardVariant.Horizontal, itemsPerRow = 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(374, size.widthPx)
|
||||||
|
assertEquals(100, size.heightPx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: RED 실행**
|
||||||
|
|
||||||
|
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLayoutCalculatorTest"`
|
||||||
|
|
||||||
|
Expected: `Unresolved reference 'CreatorRankingLayoutCalculator'`로 실패한다.
|
||||||
|
|
||||||
|
- [x] **Step 3: GREEN - layout calculator 추가**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.widget.creatorranking
|
||||||
|
|
||||||
|
object CreatorRankingLayoutCalculator {
|
||||||
|
private const val HORIZONTAL_FIGMA_WIDTH = 374
|
||||||
|
private const val HORIZONTAL_FIGMA_HEIGHT = 100
|
||||||
|
|
||||||
|
fun calculate(
|
||||||
|
parentWidthPx: Int,
|
||||||
|
horizontalGapPx: Int,
|
||||||
|
placement: CreatorRankingPlacement
|
||||||
|
): CreatorRankingCardSize {
|
||||||
|
require(parentWidthPx > 0) { "parentWidthPx must be > 0." }
|
||||||
|
require(horizontalGapPx >= 0) { "horizontalGapPx must be >= 0." }
|
||||||
|
require(placement.itemsPerRow > 0) { "itemsPerRow must be > 0." }
|
||||||
|
|
||||||
|
val totalGap = horizontalGapPx * (placement.itemsPerRow - 1)
|
||||||
|
val width = (parentWidthPx - totalGap) / placement.itemsPerRow
|
||||||
|
val height = when (placement.variant) {
|
||||||
|
CreatorRankingCardVariant.Large,
|
||||||
|
CreatorRankingCardVariant.Compact -> width
|
||||||
|
CreatorRankingCardVariant.Horizontal -> (width * HORIZONTAL_FIGMA_HEIGHT) / HORIZONTAL_FIGMA_WIDTH
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreatorRankingCardSize(widthPx = width, heightPx = height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CreatorRankingCardSize(
|
||||||
|
val widthPx: Int,
|
||||||
|
val heightPx: Int
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: GREEN 실행**
|
||||||
|
|
||||||
|
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingLayoutCalculatorTest"`
|
||||||
|
|
||||||
|
Expected: `BUILD SUCCESSFUL`
|
||||||
|
|
||||||
|
### Task 6: Android 리소스와 custom view 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/src/main/res/layout/view_creator_ranking_large_card.xml`
|
||||||
|
- Create: `app/src/main/res/layout/view_creator_ranking_compact_card.xml`
|
||||||
|
- Create: `app/src/main/res/layout/view_creator_ranking_horizontal_card.xml`
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingLargeCardView.kt`
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCompactCardView.kt`
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingHorizontalCardView.kt`
|
||||||
|
- Modify: `app/src/main/res/values/strings.xml`
|
||||||
|
- Modify: `app/src/main/res/values-en/strings.xml`
|
||||||
|
- Modify: `app/src/main/res/values-ja/strings.xml`
|
||||||
|
|
||||||
|
- [x] **Step 1: 접근 불가 문자열 추가**
|
||||||
|
|
||||||
|
Add to `app/src/main/res/values/strings.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<string name="creator_ranking_inaccessible_info">접근할 수 없는 정보입니다.</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add equivalent keys to localized files:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<string name="creator_ranking_inaccessible_info">This information is not accessible.</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<string name="creator_ranking_inaccessible_info">アクセスできない情報です。</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: 1위 전용 large card layout 추가**
|
||||||
|
|
||||||
|
`view_creator_ranking_large_card.xml`은 root custom view, image, dim gradient, rank text, large 전용 rank delta container, name text를 포함한다. image width/height는 custom view에서 계산하므로 XML에서는 `match_parent` 또는 `0dp` 초기값을 사용한다. 차단 관계 상태에서 name text가 숨겨져도 dim gradient view는 유지되어야 한다.
|
||||||
|
|
||||||
|
- [x] **Step 3: 2위~10위 공통 compact card layout 추가**
|
||||||
|
|
||||||
|
`view_creator_ranking_compact_card.xml`은 root custom view, image, dim gradient, rank text, compact 공통 rank delta container, name text를 포함한다. 2열/3열 차이는 layout 파일이 아니라 `CreatorRankingLayoutCalculator`의 결과로만 처리한다. 차단 관계 상태에서 name text가 숨겨져도 dim gradient view는 유지되어야 한다.
|
||||||
|
|
||||||
|
- [x] **Step 4: 11위 이후 horizontal card layout 추가**
|
||||||
|
|
||||||
|
`view_creator_ranking_horizontal_card.xml`은 root custom view, left rank text, center image, rank delta container, right name text를 포함한다. root height는 custom view에서 계산한다.
|
||||||
|
|
||||||
|
- [x] **Step 5: custom view 3종 구현**
|
||||||
|
|
||||||
|
Required common API:
|
||||||
|
- `fun bind(item: CreatorRankingItem)`
|
||||||
|
- `fun setCardSize(size: CreatorRankingCardSize)`
|
||||||
|
- `fun imageView(): ImageView`
|
||||||
|
- `fun setOnCreatorClick(listener: ((CreatorRankingItem) -> Unit)?)`
|
||||||
|
|
||||||
|
Required behavior:
|
||||||
|
- `CreatorRankingDeltaPresentation`을 사용해 rank delta icon과 amount 표시 여부를 결정한다.
|
||||||
|
- `CreatorRankingChangeType.Stay`이면 숫자 없이 `ic_rank_caret_stay`만 표시한다.
|
||||||
|
- `CreatorRankingChangeType.New`이면 `ic_rank_new`를 표시하고 rank delta 숫자는 숨긴다.
|
||||||
|
- `Increase`, `Decrease`는 change type별 caret icon과 `rankChangeAmount`를 표시한다.
|
||||||
|
- `Large`와 `Compact`에서 `item.displayName(...)` 결과가 빈 문자열이면 name TextView를 숨기거나 빈 값으로 둔다.
|
||||||
|
- `Large`와 `Compact`에서 name TextView를 숨겨도 dim gradient view는 숨기지 않는다.
|
||||||
|
- `Horizontal`에서 차단 관계 상태이면 이름 영역에 `creator_ranking_inaccessible_info`를 표시한다.
|
||||||
|
- `item.isInaccessible`이면 image blur 적용 지점을 제공하고 root click listener를 제거한다.
|
||||||
|
- `item.isTouchable`이면 root click listener를 연결한다.
|
||||||
|
|
||||||
|
### Task 7: Adapter 추가
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt`
|
||||||
|
|
||||||
|
- [x] **Step 1: RecyclerView adapter 추가**
|
||||||
|
|
||||||
|
Required API:
|
||||||
|
- constructor parameter: `private val onClickItem: (CreatorRankingItem) -> Unit`
|
||||||
|
- `fun submitItems(items: List<CreatorRankingItem>)`
|
||||||
|
- `override fun getItemViewType(position: Int): Int`
|
||||||
|
|
||||||
|
Required behavior:
|
||||||
|
- `CreatorRankingPlacement.fromRank(item.rank)`로 viewType과 row count를 결정한다.
|
||||||
|
- `Large`는 `CreatorRankingLargeCardView`를 사용한다.
|
||||||
|
- `Compact`는 `CreatorRankingCompactCardView`를 사용한다.
|
||||||
|
- `Horizontal`은 `CreatorRankingHorizontalCardView`를 사용한다.
|
||||||
|
- `item.isTouchable`이 false이면 click callback을 호출하지 않는다.
|
||||||
|
- 외부에서 전달한 parent width와 row count를 기준으로 `CreatorRankingLayoutCalculator`를 사용한다. parent width를 아직 알 수 없으면 `onBindViewHolder`에서 itemView의 measured width 또는 RecyclerView width를 기준으로 계산한다.
|
||||||
|
|
||||||
|
- [x] **Step 2: 기존 화면에는 아직 연결하지 않기**
|
||||||
|
|
||||||
|
이번 task에서는 adapter와 reusable widget 추가까지만 수행한다. 기존 `CreatorRankingAdapter` 또는 화면 RecyclerView 교체는 사용자가 별도 승인한 뒤 진행한다.
|
||||||
|
|
||||||
|
### Task 8: 검증 및 문서 기록
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md`
|
||||||
|
|
||||||
|
- [x] **Step 1: 단일 테스트 실행**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `BUILD SUCCESSFUL`
|
||||||
|
|
||||||
|
- [x] **Step 2: LSP 진단 실행**
|
||||||
|
|
||||||
|
Run `lsp_diagnostics` on modified Kotlin/XML files.
|
||||||
|
|
||||||
|
Expected: 새 오류가 없다. Kotlin/XML LSP가 환경에 없으면 그 사실을 검증 기록에 남긴다.
|
||||||
|
|
||||||
|
- [x] **Step 3: 리소스 병합/디버그 빌드 실행**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
./gradlew :app:assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `BUILD SUCCESSFUL`
|
||||||
|
|
||||||
|
- [x] **Step 4: ViewBinding 생성 확인**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewCreatorRanking(LargeCard|CompactCard|HorizontalCard)Binding"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 신규 layout의 ViewBinding 생성 파일이 출력된다.
|
||||||
|
|
||||||
|
- [x] **Step 5: 검증 기록 누적**
|
||||||
|
|
||||||
|
문서 하단 `검증 기록`에 실행한 명령, 결과, 빌드 성공 여부, Figma context timeout 여부를 한국어로 기록한다.
|
||||||
|
|
||||||
|
## 체크리스트
|
||||||
|
- [x] AC1: 1위는 `Large` variant로 한 줄에 1개 표시한다.
|
||||||
|
- [x] AC2: 2위~7위는 `Compact` variant로 한 줄에 2개 표시한다.
|
||||||
|
- [x] AC3: 8위~10위는 `Compact` variant로 한 줄에 3개 표시한다.
|
||||||
|
- [x] AC4: 11위 이후는 `Horizontal` variant로 한 줄에 1개 표시한다.
|
||||||
|
- [x] AC5: 이미지 크기는 고정 dp가 아니라 부모 폭과 row count로 계산한다.
|
||||||
|
- [x] AC6: 상승/하락/동일/신규 진입 rank delta UI가 각각 `ic_rank_caret_increase`, `ic_rank_caret_decrease`, `ic_rank_caret_stay`, `ic_rank_new`로 표시된다.
|
||||||
|
- [x] AC6-1: 동일 순위 상태에서는 숫자 없이 `ic_rank_caret_stay`만 표시된다.
|
||||||
|
- [x] AC7: 차단 관계 상태에서는 이미지가 블러 처리된다.
|
||||||
|
- [x] AC8: 차단 관계 상태의 1위~10위 카드에는 크리에이터 이름이 표시되지 않는다.
|
||||||
|
- [x] AC8-1: 차단 관계 상태의 1위~10위 카드에서 이름을 숨겨도 dim gradient 영역은 유지된다.
|
||||||
|
- [x] AC9: 차단 관계 상태의 11위 이후 카드에는 `접근할 수 없는 정보입니다.`가 표시된다.
|
||||||
|
- [x] AC10: 차단 관계 상태의 카드는 터치할 수 없다.
|
||||||
|
- [x] AC11: 접근 가능 상태의 카드는 터치 가능하고 click callback을 호출한다.
|
||||||
|
- [x] AC12: 기존 화면 파일은 사용자 추가 승인 없이 교체하지 않는다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
- 2026-05-20
|
||||||
|
- 무엇/왜/어떻게: 사용자 요청에 따라 구현 전 PRD와 구현 계획/TASK 문서만 작성했다. Figma 4개 노드는 크리에이터 랭킹 위젯의 순위 구간별 variant로 정리했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `Figma_get_design_context(20:3702)`
|
||||||
|
- `Figma_get_design_context(20:3709)`
|
||||||
|
- `Figma_get_design_context(20:3711)`
|
||||||
|
- `Figma_get_design_context(20:3713)`
|
||||||
|
- `Figma_get_metadata(20:3702)`
|
||||||
|
- `Figma_get_metadata(20:3709)`
|
||||||
|
- `Figma_get_metadata(20:3711)`
|
||||||
|
- `Figma_get_metadata(20:3713)`
|
||||||
|
- `Figma_get_screenshot(20:3702)`
|
||||||
|
- `Figma_get_screenshot(20:3709)`
|
||||||
|
- `Figma_get_screenshot(20:3711)`
|
||||||
|
- `Figma_get_screenshot(20:3713)`
|
||||||
|
- `read(docs/agent-guides/workflow-docs-commits.md)`
|
||||||
|
- `read(docs/prd/sample-prd.md)`
|
||||||
|
- `read(docs/prd/20260519_오디오콘텐츠카드컴포넌트_prd.md)`
|
||||||
|
- `read(docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md)`
|
||||||
|
- `rg -n "ic_rank_caret|rank_new|rank|BlurTransformation|blur" app/src/main app/src/test docs`
|
||||||
|
- `read(app/src/main/java/kr/co/vividnext/sodalive/home/CreatorRankingAdapter.kt)`
|
||||||
|
- `read(app/src/main/res/layout/item_home_creator.xml)`
|
||||||
|
- `read(app/src/main/res/layout/fragment_audio_content_main_tab_home.xml)`
|
||||||
|
- `read(app/src/main/java/kr/co/vividnext/sodalive/common/image/BlurTransformation.kt)`
|
||||||
|
- `rg -n "data class GetExplorerSectionCreatorResponse|class GetExplorerSectionCreatorResponse|blocked|block" app/src/main/java/kr/co/vividnext/sodalive/explorer app/src/main/java/kr/co/vividnext/sodalive/home app/src/main/java/kr/co/vividnext/sodalive/audio_content`
|
||||||
|
- `read(app/src/main/java/kr/co/vividnext/sodalive/explorer/GetExplorerResponse.kt)`
|
||||||
|
- 결과:
|
||||||
|
- PRD 문서는 `docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md`에 작성했다.
|
||||||
|
- 계획/TASK 문서는 `docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md`에 작성했다.
|
||||||
|
- `Figma_get_design_context`는 4개 노드 모두 timeout이었다. 대신 metadata와 screenshot으로 노드 형태를 확인했다.
|
||||||
|
- metadata 크기는 참고용으로만 확인했고 구현 크기는 고정하지 않는다.
|
||||||
|
- 현재 `GetExplorerSectionCreatorResponse`에는 이전 순위/변동 상태/차단 관계 여부 필드가 없어 구현 전 데이터 계약 확인이 필요하다.
|
||||||
|
- 코드, 리소스, 레이아웃 구현 파일은 변경하지 않았다.
|
||||||
|
- 실제 구현과 빌드 검증은 사용자 승인 후 계획 문서 체크리스트에 따라 진행한다.
|
||||||
|
- 2026-05-20
|
||||||
|
- 무엇/왜/어떻게: 사용자 피드백에 따라 문서의 데이터 계약과 카드 variant 정책을 수정했다. 차단 방향 구분을 제거해 `isBlocked` 단일 필드로 줄이고, Figma metadata size 고정값을 구현 크기 기준에서 제거했다. 2위~10위는 `Compact` 단일 UI로 통합하고, 1위 `Large`는 순위 표시가 달라 별도 카드로 유지했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `read(docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md)`
|
||||||
|
- `read(docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md)`
|
||||||
|
- 결과:
|
||||||
|
- `Data Contract Requirements`의 `isBlockedByMe`, `hasBlockedMe`를 `isBlocked`로 축소했다.
|
||||||
|
- `Metadata size` 고정 크기 표기를 제거하고, 실제 사용 영역 폭과 row count 기반 크기 계산으로 변경했다.
|
||||||
|
- `Medium`, `Small` variant를 제거하고 2위~10위 공통 `Compact` variant로 통일했다.
|
||||||
|
- 1위 전용 `Large` 카드와 11위 이후 `Horizontal` 카드는 별도 variant로 유지했다.
|
||||||
|
- 2026-05-20
|
||||||
|
- 무엇/왜/어떻게: 사용자 확정 사항과 Figma 재확인 결과를 문서에 반영했다. 순위 동일 상태는 숫자 없이 stay 아이콘만 표시하고, 차단 관계 상태에서 1위~10위 이름을 숨겨도 dim gradient는 유지한다. `Figma_get_design_context` 재시도에 성공해 typography/color/radius 토큰을 PRD와 계획 문서에 반영했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `Figma_get_design_context(20:3702)`
|
||||||
|
- `Figma_get_design_context(20:3709)`
|
||||||
|
- `Figma_get_design_context(20:3711)`
|
||||||
|
- `Figma_get_design_context(20:3713)`
|
||||||
|
- `Figma_get_screenshot(20:3702)`
|
||||||
|
- `Figma_get_screenshot(20:3709)`
|
||||||
|
- `Figma_get_screenshot(20:3711)`
|
||||||
|
- `Figma_get_screenshot(20:3713)`
|
||||||
|
- 결과:
|
||||||
|
- `rankChangeType == Stay`는 `ic_rank_caret_stay` 아이콘만 표시하고 숫자를 숨기도록 확정했다.
|
||||||
|
- 차단 관계 상태에서 1위~10위 카드의 creator name은 숨기되 dim gradient overlay는 유지하도록 확정했다.
|
||||||
|
- 공통 radius `14dp`, rank-num 배경 `gray_900 #202020`, rank-num radius `4dp`, gap `2dp`, caret `14dp`, dim gradient opacity `50%`, Pretendard/Pattaya typography 기준을 문서화했다.
|
||||||
|
- 2026-05-20
|
||||||
|
- 무엇/왜/어떻게: 계획 문서 기준으로 creator ranking reusable widget 구현과 검증을 수행했다. 순수 contract는 TDD로 RED 실패를 확인한 뒤 GREEN 구현했고, Android layout/custom view/adapter는 기존 화면에 연결하지 않는 범위로 추가했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
|
||||||
|
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingAdapter.kt)`
|
||||||
|
- `./gradlew :app:assembleDebug`
|
||||||
|
- `rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewCreatorRanking(LargeCard|CompactCard|HorizontalCard)Binding"`
|
||||||
|
- 결과:
|
||||||
|
- creator ranking 단위 테스트는 `BUILD SUCCESSFUL`로 통과했다.
|
||||||
|
- Kotlin LSP는 현재 환경에 `.kt` 확장자 서버가 없어 `No LSP server configured for extension: .kt`로 진단을 수행할 수 없었다.
|
||||||
|
- `:app:assembleDebug`는 `BUILD SUCCESSFUL`로 통과했다.
|
||||||
|
- `ViewCreatorRankingLargeCardBinding`, `ViewCreatorRankingCompactCardBinding`, `ViewCreatorRankingHorizontalCardBinding` 생성 파일을 확인했다.
|
||||||
|
- 기존 화면 교체 없이 `kr.co.vividnext.sodalive.v2.widget.creatorranking` 하위 reusable widget만 추가했다.
|
||||||
|
- 2026-05-20
|
||||||
|
- 무엇/왜/어떻게: 구현 후 코드 리뷰에서 지적된 row 배치 보조 API, API 23~30 blur fallback, `New` rank delta 표시 크기/배경 문제를 보강했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `requesting-code-review` 기반 읽기 전용 리뷰
|
||||||
|
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.*"`
|
||||||
|
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking)`
|
||||||
|
- `./gradlew :app:assembleDebug`
|
||||||
|
- `rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewCreatorRanking(LargeCard|CompactCard|HorizontalCard)Binding"`
|
||||||
|
- 결과:
|
||||||
|
- `CreatorRankingAdapter.GRID_SPAN_COUNT`, `createSpanSizeLookup()`, `createGridLayoutManager(context)`를 추가해 1위/11위 이후 full span, 2위~7위 2열, 8위~10위 3열 구성을 호출부가 적용할 수 있게 했다.
|
||||||
|
- API 31 미만 차단 이미지에는 기존 `BlurTransformation` 기반 Coil blur fallback을 적용하고, API 31 이상 `RenderEffect` 경로는 유지했다.
|
||||||
|
- `CreatorRankingChangeType.New`는 pill 배경 없이 `36dp x 23dp` 아이콘으로 표시하고, caret 계열은 기존 `14dp x 14dp` pill 표시를 유지했다.
|
||||||
|
- creator ranking 단위 테스트와 `:app:assembleDebug`는 모두 `BUILD SUCCESSFUL`로 통과했다.
|
||||||
|
- Kotlin LSP는 현재 환경에 `.kt` 확장자 서버가 없어 `No LSP server configured for extension: .kt`로 진단을 수행할 수 없었다.
|
||||||
|
- 보강 후 재리뷰에서 기존 Important 3건은 모두 해소됐고 새 Critical/Important 이슈는 없음을 확인했다.
|
||||||
178
docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md
Normal file
178
docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# PRD: 크리에이터 랭킹 위젯 컴포넌트
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
Figma `20:3702`, `20:3709`, `20:3711`, `20:3713` 디자인을 기준으로 크리에이터 랭킹을 순위 구간별 카드 형태로 표현하는 Android XML Views 기반 위젯 컴포넌트를 개발한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 크리에이터 랭킹은 순위 구간에 따라 카드 UI와 한 줄 배치 개수가 달라져야 한다.
|
||||||
|
- 기기 폭이 하나로 고정되지 않으므로 Figma metadata size를 실제 이미지 크기로 고정하면 다양한 화면 폭에서 재사용하기 어렵다.
|
||||||
|
- 2위~10위는 순위 구간별 row count는 다르지만 카드 UI는 동일하므로, 별도 medium/small 컴포넌트로 나누면 중복 구현이 생긴다.
|
||||||
|
- 순위 변동, 신규 진입, 차단 상태, 터치 가능 여부가 함께 표시되어야 하므로 데이터와 UI 상태 계약을 명확히 해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- Figma 4개 노드 기준의 크리에이터 랭킹 카드 variant와 row 배치 정책을 제공한다.
|
||||||
|
- 이미지 크기는 컴포넌트 내부에서 고정하지 않고 실제 사용하는 row container 폭과 row count에 맞춰 계산한다.
|
||||||
|
- 순위 구간별 한 줄 배치 규칙을 제공한다.
|
||||||
|
- 1위: 한 줄에 1개, Figma `20:3702`, 큰 정사각형 카드.
|
||||||
|
- 2위~7위: 한 줄에 2개, Figma `20:3709`, 기본 정사각형 카드.
|
||||||
|
- 8위~10위: 한 줄에 3개, Figma `20:3711`, 기본 정사각형 카드.
|
||||||
|
- 11위 이후: 가로형으로 한 줄에 1개, Figma `20:3713`, 가로형 카드.
|
||||||
|
- 2위~7위와 8위~10위는 UI가 동일하므로 하나의 기본 정사각형 카드 컴포넌트로 통일한다.
|
||||||
|
- 1위 큰 카드는 순위 표시 부분이 다르므로 별도 큰 카드 컴포넌트로 작성한다.
|
||||||
|
- `rank-num` 영역은 이전 순위와 비교한 변동 상태를 표시한다.
|
||||||
|
- 차단 관계인 크리에이터는 이미지 블러, 이름 비노출 또는 대체문구, 터치 불가 상태로 표시한다.
|
||||||
|
- 기존 화면 일괄 적용은 구현 계획에서 별도 task로 제한하고, 컴포넌트 계약을 우선 고정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 이번 범위에서는 서버 API 설계나 응답 필드명을 확정하지 않는다. 필요한 클라이언트 데이터 계약만 문서화한다.
|
||||||
|
- 팔로우 버튼, 후원 랭킹, 콘텐츠 랭킹, 라이브 랭킹 UI는 변경하지 않는다.
|
||||||
|
- Compose 컴포넌트 또는 Compose Theme를 추가하지 않는다.
|
||||||
|
- Figma에 없는 skeleton loading, shimmer, pressed animation, 별도 badge, 광고 영역, 페이지네이션 UI를 추가하지 않는다.
|
||||||
|
- 차단/차단 해제 기능 자체를 새로 만들지 않는다.
|
||||||
|
- 내가 차단했는지, 나를 차단했는지를 UI에서 구분해 표시하지 않는다.
|
||||||
|
- 이미지 로딩 라이브러리 교체를 수행하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 크리에이터 랭킹 화면을 보는 앱 사용자.
|
||||||
|
- XML 레이아웃과 RecyclerView 기반 랭킹 UI를 구현/유지보수하는 Android 개발자.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 크리에이터 랭킹의 상위 순위를 더 큰 카드로 빠르게 확인하고 싶다.
|
||||||
|
- 사용자는 순위가 올랐는지, 내려갔는지, 유지됐는지, 신규 진입했는지 알고 싶다.
|
||||||
|
- 사용자는 차단 관계에 있는 크리에이터의 상세 정보가 노출되지 않기를 기대한다.
|
||||||
|
- 개발자는 순위 구간별 UI를 하나의 명확한 계약으로 바인딩하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Creator Ranking Widget
|
||||||
|
크리에이터 랭킹 목록을 순위 구간별 카드 variant와 행 배치 규칙으로 표시한다.
|
||||||
|
|
||||||
|
#### Figma References
|
||||||
|
- Rank 1 large card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3702&m=dev
|
||||||
|
- Rank 2~7 compact card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3709&m=dev
|
||||||
|
- Rank 8~10 compact card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3711&m=dev
|
||||||
|
- Rank 11+ horizontal card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3713&m=dev
|
||||||
|
|
||||||
|
#### Variant and Row Requirements
|
||||||
|
| Rank range | Figma node | Row count | UI variant | Size policy |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | `20:3702` | 한 줄에 1개 | `Large` | 실제 사용 영역 폭을 1등분해 정사각형으로 표시 |
|
||||||
|
| 2~7 | `20:3709` | 한 줄에 2개 | `Compact` | 실제 사용 영역 폭을 2등분해 정사각형으로 표시 |
|
||||||
|
| 8~10 | `20:3711` | 한 줄에 3개 | `Compact` | 실제 사용 영역 폭을 3등분해 정사각형으로 표시 |
|
||||||
|
| 11+ | `20:3713` | 한 줄에 1개 | `Horizontal` | 실제 사용 영역 폭을 1등분하고 Figma 가로형 비율로 표시 |
|
||||||
|
|
||||||
|
#### Variant Details
|
||||||
|
- `Large`: 1위 전용 카드다. 순위 숫자와 순위 변동 표시 영역이 다른 정사각형 카드와 다르므로 별도 layout/custom view로 작성한다.
|
||||||
|
- `Compact`: 2위~10위 공통 정사각형 카드다. 2위~7위와 8위~10위는 row count만 다르고 UI 구조는 동일하다.
|
||||||
|
- `Horizontal`: 11위 이후 전용 가로형 카드다.
|
||||||
|
- Figma metadata size는 참고용 비율 확인에만 사용하고, 구현에서 고정 dp 크기로 사용하지 않는다.
|
||||||
|
|
||||||
|
#### Figma Token Requirements
|
||||||
|
- 공통 카드 이미지 radius는 `radius_14` 또는 `14dp`를 사용한다.
|
||||||
|
- 공통 dim gradient는 위쪽 `rgba(0,0,0,0)`, 아래쪽 black, opacity `50%`, 전환 시작점 `64.423%` 기준으로 구현한다.
|
||||||
|
- 공통 `rank-num` 배경은 `gray_900` (`#202020`), radius `4dp`, horizontal padding `4dp`, gap `2dp`를 사용한다.
|
||||||
|
- 공통 `rank-num` 숫자는 Pretendard Variable Medium, `16sp`, line-height `1.45`, color white를 사용한다.
|
||||||
|
- 공통 caret icon 크기는 `14dp x 14dp`를 기준으로 한다.
|
||||||
|
- 순위 숫자는 Pattaya Regular를 사용하고 white~`#EEEEEE` vertical gradient와 `0px 0px 4px rgba(0,0,0,0.48)` shadow를 적용한다.
|
||||||
|
- `Large` creator name은 Pretendard Variable Bold, `32sp`, line-height `1.45`, color white를 기준으로 한다.
|
||||||
|
- 2열 `Compact` creator name은 Pretendard Variable Bold, `22sp`, line-height `1.45`, color white를 기준으로 한다.
|
||||||
|
- 3열 `Compact` creator name은 Pretendard Variable Bold, `14sp`, line-height normal, color white를 기준으로 한다.
|
||||||
|
- `Horizontal` creator name은 Pretendard Variable Bold, `18sp`, line-height `1.45`, color white를 기준으로 한다.
|
||||||
|
|
||||||
|
#### Display Requirements
|
||||||
|
- 모든 variant는 현재 순위 숫자를 표시한다.
|
||||||
|
- `rank-num` 영역은 순위 변동 상태를 표시한다.
|
||||||
|
- 순위 상승: `ic_rank_caret_increase`.
|
||||||
|
- 순위 하락: `ic_rank_caret_decrease`.
|
||||||
|
- 순위 동일: 숫자 없이 `ic_rank_caret_stay` 아이콘만 표시.
|
||||||
|
- 신규 진입: `rank-num` 대신 `ic_rank_new` 이미지.
|
||||||
|
- 신규 진입이 아니고 순위 동일이 아닌 경우 `rank-num`에는 이전 순위 대비 변동 숫자를 표시한다.
|
||||||
|
- 1위~10위의 카드 이름 영역은 정상 접근 가능 상태에서만 크리에이터 이름을 표시한다.
|
||||||
|
- 11위 이후 가로형 카드는 정상 접근 가능 상태에서 크리에이터 이름을 표시한다.
|
||||||
|
|
||||||
|
#### Blocked Creator Requirements
|
||||||
|
- 내가 차단했거나 나를 차단한 크리에이터는 하나의 차단 관계 상태로만 전달받는다.
|
||||||
|
- 차단 관계 상태에서는 프로필/대표 이미지를 블러 처리한다.
|
||||||
|
- 차단 관계 상태의 1위~10위 카드는 크리에이터 이름을 표시하지 않는다.
|
||||||
|
- 차단 관계 상태의 1위~10위 카드는 이름을 숨겨도 하단 dim gradient 영역은 유지한다.
|
||||||
|
- 차단 관계 상태의 11위 이후 가로형 카드는 크리에이터 이름 대신 `접근할 수 없는 정보입니다.`를 표시한다.
|
||||||
|
- 차단 관계 상태의 카드는 터치할 수 없다.
|
||||||
|
- 접근 가능 상태의 카드는 터치할 수 있고, 터치 시 호출부가 크리에이터 상세 이동 등 후속 동작을 처리한다.
|
||||||
|
|
||||||
|
#### Data Contract Requirements
|
||||||
|
- 최소 데이터 계약은 다음 정보를 포함해야 한다.
|
||||||
|
- `creatorId`: 크리에이터 식별자.
|
||||||
|
- `rank`: 현재 순위. 1부터 시작한다.
|
||||||
|
- `previousRank`: 이전 순위. 신규 진입이면 null 허용.
|
||||||
|
- `rankChangeType`: `increase`, `decrease`, `stay`, `new` 중 하나.
|
||||||
|
- `rankChangeAmount`: 신규 진입이 아닌 경우 표시할 변동 숫자.
|
||||||
|
- `creatorName`: 크리에이터 이름.
|
||||||
|
- `imageUrl`: 카드 이미지 URL.
|
||||||
|
- `isBlocked`: 내가 차단했거나 나를 차단한 차단 관계 여부.
|
||||||
|
- UI는 `isBlocked`만 사용하고 차단 방향은 구분하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 랭킹 데이터가 0개이면 위젯 영역은 표시하지 않거나 호출부의 empty 정책을 따른다.
|
||||||
|
- 랭킹 데이터가 1~10개이면 존재하는 순위까지만 구간 규칙을 적용한다.
|
||||||
|
- `rank`가 누락되거나 1보다 작으면 호출부 데이터 오류로 간주하고 해당 항목을 표시하지 않는다.
|
||||||
|
- `rankChangeType`이 `new`이면 `previousRank`와 `rankChangeAmount`가 없어도 된다.
|
||||||
|
- `rankChangeType`이 `stay`이면 `rankChangeAmount`가 있어도 숫자를 표시하지 않는다.
|
||||||
|
- `rankChangeType`이 `increase` 또는 `decrease`인데 `rankChangeAmount`가 없으면 구현 단계에서 데이터 검증 정책을 정한다.
|
||||||
|
- 이미지 로딩 실패 시 placeholder 정책은 기존 이미지 로딩 계층 또는 호출 화면 정책을 따른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UX / UI Expectations
|
||||||
|
- 전체 위젯은 어두운 배경 위에서 사용하는 것을 전제로 한다.
|
||||||
|
- Figma 스크린샷 기준 카드 이미지는 rounded corner를 가진다.
|
||||||
|
- 카드 이미지는 공통 dim gradient를 가진다. 차단 관계 상태에서 이름을 숨기더라도 1위~10위의 gradient overlay는 유지한다.
|
||||||
|
- `Large` 카드는 1위 전용 순위 표시 형태를 따른다.
|
||||||
|
- `Compact` 카드는 2위~10위에서 동일 UI를 사용하고, row count에 따라 표시 크기만 달라진다.
|
||||||
|
- `Horizontal` 카드는 좌측 순위, 중앙 이미지, 우측 이름 영역을 가진다.
|
||||||
|
- 이미지 크기는 고정 dp로 박지 않고 row container 폭에서 계산한다.
|
||||||
|
- 정사각형 variant는 계산된 카드 폭과 동일한 높이로 표시한다.
|
||||||
|
- 가로형 variant는 부모 폭을 채우고 Figma 가로형 비율에 맞는 높이를 유지한다.
|
||||||
|
- 차단 관계 상태는 사용자가 상세로 들어갈 수 없음을 시각적으로 알 수 있어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Technical Constraints
|
||||||
|
- 현재 프로젝트는 Android XML Views + ViewBinding + RecyclerView 기반이므로 XML 레이아웃과 Kotlin custom view/adapter 패턴을 우선한다.
|
||||||
|
- 신규 Kotlin 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
|
||||||
|
- 재사용 가능한 위젯은 `kr.co.vividnext.sodalive.v2.widget` 또는 기능 범위에 맞는 하위 패키지에 둔다.
|
||||||
|
- 기존 `CreatorRankingAdapter`와 `item_home_creator.xml`는 레거시 홈 크리에이터 카드이므로, 기존 화면 변경 없이 새 컴포넌트의 참고 대상으로만 사용한다.
|
||||||
|
- 기존 프로젝트의 이미지 로딩 방식이 화면별로 Glide/Coil을 함께 사용하므로, 컴포넌트 내부에 특정 이미지 로더를 강제하지 않는 API를 우선한다.
|
||||||
|
- 차단 관계 이미지 블러는 기존 `kr.co.vividnext.sodalive.common.image.BlurTransformation` 등 기존 blur 구현의 재사용 가능성을 먼저 검토한다.
|
||||||
|
- `ic_rank_caret_increase`, `ic_rank_caret_decrease`, `ic_rank_caret_stay`, `ic_rank_new` 리소스가 없으면 구현 단계에서 디자인 에셋 추가가 필요하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Metrics
|
||||||
|
- 순위 구간별 row count가 요구사항과 일치한다.
|
||||||
|
- 1위는 `Large`, 2위~10위는 `Compact`, 11위 이후는 `Horizontal` variant로 바인딩된다.
|
||||||
|
- `rankChangeType`별 아이콘/숫자 표시가 문서와 일치한다.
|
||||||
|
- `stay` 상태에서는 숫자 없이 `ic_rank_caret_stay`만 표시된다.
|
||||||
|
- 차단 관계 상태에서 이미지 블러, 이름 비노출/대체문구, 터치 불가가 모두 적용된다.
|
||||||
|
- 차단 관계 상태에서 1위~10위 카드의 gradient overlay가 유지된다.
|
||||||
|
- 이미지 크기가 고정 dp가 아닌 부모 폭과 row count 기반으로 계산된다.
|
||||||
|
- 관련 unit test와 Android resource merge/build가 성공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Open Questions
|
||||||
|
- 서버 응답에 이전 순위, 신규 진입 여부, 차단 관계 여부가 이미 포함되는지 확인이 필요하다. 없으면 API/DTO 확장이 별도 백엔드 협의 항목이다.
|
||||||
|
- Figma `get_design_context` 재확인 결과 typography/color/radius 토큰은 본 문서의 `Figma Token Requirements`에 반영했다.
|
||||||
|
- `rankChangeAmount`가 순위 동일일 때는 숫자 없이 `ic_rank_caret_stay` 아이콘만 표시하는 것으로 확정했다.
|
||||||
|
- 차단 관계 상태에서 1위~10위 카드의 이름 영역을 숨길 때 gradient 영역은 유지하는 것으로 확정했다.
|
||||||
Reference in New Issue
Block a user