diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingAdapter.kt new file mode 100644 index 00000000..b8a14614 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingAdapter.kt @@ -0,0 +1,170 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.extensions.loadUrl +import kotlin.math.roundToInt + +class ContentRankingAdapter( + private val onClickItem: (ContentRankingItem) -> Unit +) : RecyclerView.Adapter() { + + private val items = mutableListOf() + + override fun getItemViewType(position: Int): Int = when (ContentRankingPlacement.fromRank(items[position].rank).variant) { + ContentRankingCardVariant.Large -> VIEW_TYPE_LARGE + ContentRankingCardVariant.MediumGrid -> VIEW_TYPE_MEDIUM_GRID + ContentRankingCardVariant.SmallGrid -> VIEW_TYPE_SMALL_GRID + ContentRankingCardVariant.Horizontal -> VIEW_TYPE_HORIZONTAL + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_LARGE -> LargeViewHolder( + inflater.inflate(R.layout.view_content_ranking_large_card, parent, false) as ContentRankingLargeCardView, + parent + ) + VIEW_TYPE_MEDIUM_GRID -> MediumGridViewHolder( + inflater.inflate(R.layout.view_content_ranking_medium_grid_card, parent, false) as ContentRankingMediumGridCardView, + parent + ) + VIEW_TYPE_SMALL_GRID -> SmallGridViewHolder( + inflater.inflate(R.layout.view_content_ranking_small_grid_card, parent, false) as ContentRankingSmallGridCardView, + parent + ) + VIEW_TYPE_HORIZONTAL -> HorizontalViewHolder( + inflater.inflate(R.layout.view_content_ranking_horizontal_card, parent, false) as ContentRankingHorizontalCardView, + parent + ) + else -> error("Unknown viewType: $viewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = items[position] + when (holder) { + is LargeViewHolder -> holder.bind(item) + is MediumGridViewHolder -> holder.bind(item) + is SmallGridViewHolder -> holder.bind(item) + is HorizontalViewHolder -> holder.bind(item) + } + } + + override fun getItemCount(): Int = items.size + + fun submitItems(items: List) { + this.items.clear() + this.items.addAll(items) + notifyDataSetChanged() + } + + private inner class LargeViewHolder( + private val view: ContentRankingLargeCardView, + private val parent: ViewGroup + ) : RecyclerView.ViewHolder(view) { + fun bind(item: ContentRankingItem) { + val size = calculateSize(item, parent) + view.setCardSize(size) + view.imageView().loadContentImage(item) + view.backgroundImageView().loadContentImage(item) + view.bind(item) + view.setOnContentClick(onClickItem) + } + } + + private inner class MediumGridViewHolder( + private val view: ContentRankingMediumGridCardView, + private val parent: ViewGroup + ) : RecyclerView.ViewHolder(view) { + fun bind(item: ContentRankingItem) { + bindGrid(view, item, parent) + } + } + + private inner class SmallGridViewHolder( + private val view: ContentRankingSmallGridCardView, + private val parent: ViewGroup + ) : RecyclerView.ViewHolder(view) { + fun bind(item: ContentRankingItem) { + bindGrid(view, item, parent) + } + } + + private inner class HorizontalViewHolder( + private val view: ContentRankingHorizontalCardView, + private val parent: ViewGroup + ) : RecyclerView.ViewHolder(view) { + fun bind(item: ContentRankingItem) { + val size = calculateSize(item, parent) + view.setCardSize(size) + view.imageView().loadContentImage(item) + view.bind(item) + view.setOnContentClick(onClickItem) + } + } + + private fun bindGrid( + view: ContentRankingGridCardView, + item: ContentRankingItem, + parent: ViewGroup + ) { + val size = calculateSize(item, parent) + view.setCardSize(size) + view.imageView().loadContentImage(item) + view.bind(item) + view.setOnContentClick(onClickItem) + } + + private fun ImageView.loadContentImage(item: ContentRankingItem) { + loadUrl(item.imageUrl) { + val blurTransformations = ContentRankingBlur.transformations(context, item.isInaccessible) + if (blurTransformations.isNotEmpty()) { + transformations(blurTransformations) + } + } + } + + private fun calculateSize( + item: ContentRankingItem, + parent: ViewGroup + ): ContentRankingCardSize { + val parentWidth = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels + return ContentRankingLayoutCalculator.calculate( + parentWidthPx = parentWidth, + parentHorizontalPaddingPx = parent.paddingLeft + parent.paddingRight, + horizontalGapPx = HORIZONTAL_GAP_DP.dpToPx(parent), + placement = ContentRankingPlacement.fromRank(item.rank) + ) + } + + private fun Int.dpToPx(parent: ViewGroup): Int = (this * parent.resources.displayMetrics.density).roundToInt() + + companion object { + const val GRID_SPAN_COUNT = 6 + + fun createGridLayoutManager(context: Context): GridLayoutManager = GridLayoutManager(context, GRID_SPAN_COUNT).apply { + spanSizeLookup = createSpanSizeLookup() + } + + fun createSpanSizeLookup(): GridLayoutManager.SpanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int = when (ContentRankingPlacement.fromRank(position + 1).itemsPerRow) { + 1 -> GRID_SPAN_COUNT + 2 -> GRID_SPAN_COUNT / 2 + 3 -> GRID_SPAN_COUNT / 3 + else -> GRID_SPAN_COUNT + } + } + + private const val VIEW_TYPE_LARGE = 1 + private const val VIEW_TYPE_MEDIUM_GRID = 2 + private const val VIEW_TYPE_SMALL_GRID = 3 + private const val VIEW_TYPE_HORIZONTAL = 4 + private const val HORIZONTAL_GAP_DP = 4 + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingBlur.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingBlur.kt new file mode 100644 index 00000000..1e545d2e --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingBlur.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import android.content.Context +import android.os.Build +import coil.transform.Transformation +import kr.co.vividnext.sodalive.common.image.BlurTransformation + +internal object ContentRankingBlur { + + fun shouldUseCoilBlur( + enabled: Boolean, + sdkInt: Int = Build.VERSION.SDK_INT + ): Boolean = enabled && sdkInt < Build.VERSION_CODES.S + + fun transformations( + context: Context, + enabled: Boolean, + sdkInt: Int = Build.VERSION.SDK_INT + ): List = 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 +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardVariant.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardVariant.kt new file mode 100644 index 00000000..64afeafa --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardVariant.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +enum class ContentRankingCardVariant { + Large, + MediumGrid, + SmallGrid, + Horizontal +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentation.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentation.kt new file mode 100644 index 00000000..d85da338 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentation.kt @@ -0,0 +1,52 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import androidx.annotation.DrawableRes +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType + +data class ContentRankingDeltaPresentation( + @get:DrawableRes val iconRes: Int, + val amountText: String, + val showAmount: Boolean, + val replaceWithNewIcon: Boolean, + val showPillBackground: Boolean, + val iconWidthDp: Int, + val iconHeightDp: Int, + val iconMarginStartDp: Int +) { + companion object { + fun from(type: RankingChangeType, amount: Int?): ContentRankingDeltaPresentation = when (type) { + RankingChangeType.Increase -> caret(R.drawable.ic_rank_caret_increase, requireNotNull(amount).toString()) + RankingChangeType.Decrease -> caret(R.drawable.ic_rank_caret_decrease, requireNotNull(amount).toString()) + RankingChangeType.Stay -> caret(R.drawable.ic_rank_caret_stay, "", showAmount = false) + RankingChangeType.New -> ContentRankingDeltaPresentation( + iconRes = R.drawable.ic_rank_new, + amountText = "", + showAmount = false, + replaceWithNewIcon = true, + showPillBackground = false, + iconWidthDp = 36, + iconHeightDp = 23, + iconMarginStartDp = 0 + ) + } + + private fun caret( + iconRes: Int, + amountText: String, + showAmount: Boolean = true + ) = ContentRankingDeltaPresentation( + iconRes = iconRes, + amountText = amountText, + showAmount = showAmount, + replaceWithNewIcon = false, + showPillBackground = true, + iconWidthDp = CARET_ICON_SIZE_DP, + iconHeightDp = CARET_ICON_SIZE_DP, + iconMarginStartDp = if (showAmount) CARET_ICON_MARGIN_START_DP else 0 + ) + + private const val CARET_ICON_SIZE_DP = 14 + private const val CARET_ICON_MARGIN_START_DP = 2 + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingGridCardView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingGridCardView.kt new file mode 100644 index 00000000..723d1632 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingGridCardView.kt @@ -0,0 +1,178 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import android.content.Context +import android.graphics.Outline +import android.graphics.RenderEffect +import android.graphics.Shader +import android.os.Build +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import kr.co.vividnext.sodalive.R +import kotlin.math.roundToInt + +open class ContentRankingGridCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private var image: ImageView? = null + private var deltaGroup: View? = null + private var rankText: TextView? = null + private var deltaAmountText: TextView? = null + private var deltaIcon: ImageView? = null + private var titleText: TextView? = null + private var creatorText: TextView? = null + private var currentItem: ContentRankingItem? = null + private var clickListener: ((ContentRankingItem) -> Unit)? = null + + override fun onFinishInflate() { + super.onFinishInflate() + image = findViewById(R.id.iv_content_ranking_image) + deltaGroup = findViewById(R.id.ll_content_ranking_delta) + rankText = findViewById(R.id.tv_content_ranking_rank) + deltaAmountText = findViewById(R.id.tv_content_ranking_delta_amount) + deltaIcon = findViewById(R.id.iv_content_ranking_delta_icon) + titleText = findViewById(R.id.tv_content_ranking_title) + creatorText = findViewById(R.id.tv_content_ranking_creator) + clipToOutline = true + outlineProvider = roundOutlineProvider() + imageView().outlineProvider = roundOutlineProvider() + imageView().clipToOutline = true + } + + fun bind(item: ContentRankingItem) { + currentItem = item + requireNotNull(rankText).apply { + text = item.rank.toString() + applyContentRankingRankGradient() + } + bindDelta(item) + requireNotNull(titleText).apply { + text = item.displayContentName(context.getString(R.string.content_ranking_inaccessible_info)) + visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE + } + requireNotNull(creatorText).apply { + text = item.displayCreatorName() + visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE + } + applyAccessState(item) + } + + fun setCardSize(size: ContentRankingCardSize) { + layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply { + width = size.widthPx + height = size.heightPx + } + positionViews(size) + } + + fun imageView(): ImageView = requireNotNull(image) + + fun setOnContentClick(listener: ((ContentRankingItem) -> Unit)?) { + clickListener = listener + currentItem?.let(::applyAccessState) + } + + private fun bindDelta(item: ContentRankingItem) { + val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount) + applyDeltaContainer(presentation) + requireNotNull(deltaIcon).apply { + setImageResource(presentation.iconRes) + layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply { + width = presentation.iconWidthDp.dpToPx() + height = presentation.iconHeightDp.dpToPx() + marginStart = presentation.iconMarginStartDp.dpToPx() + } + } + requireNotNull(deltaAmountText).apply { + text = presentation.amountText + visibility = if (presentation.showAmount) View.VISIBLE else View.GONE + } + } + + private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) { + requireNotNull(deltaGroup).apply { + setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0) + val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0 + setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom) + } + } + + private fun applyAccessState(item: ContentRankingItem) { + applyBlur(item.isInaccessible) + isClickable = item.isTouchable && clickListener != null + setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null) + } + + private fun positionViews(size: ContentRankingCardSize) { + if (size.widthPx <= SMALL_THRESHOLD_PX) positionSmall(size) else positionMedium(size) + requireNotNull(rankText).applyContentRankingRankGradient() + } + + private fun positionMedium(size: ContentRankingCardSize) { + val scale = size.widthPx / 185f + requireNotNull(rankText).apply { + textSize = 54f + layoutParams = LayoutParams((55 * scale).roundToInt(), (75 * scale).roundToInt()) + } + requireNotNull(titleText).textSize = 22f + placeDelta(left = 10, top = 70, scale = scale) + placeLabel(width = 165, top = 136, scale = scale, size = size) + } + + private fun positionSmall(size: ContentRankingCardSize) { + val scale = size.widthPx / 122f + requireNotNull(rankText).apply { + textSize = 40f + layoutParams = LayoutParams((42 * scale).roundToInt(), (56 * scale).roundToInt()).apply { + leftMargin = (8 * scale).roundToInt() + } + } + requireNotNull(titleText).textSize = 14f + placeDelta(left = 10, top = 49, scale = scale) + placeLabel(width = 102, top = 81, scale = scale, size = size) + } + + private fun placeDelta(left: Int, top: Int, scale: Float) { + requireNotNull(deltaGroup).layoutParams = LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + leftMargin = (left * scale).roundToInt() + topMargin = (top * scale).roundToInt() + } + } + + private fun placeLabel(width: Int, top: Int, scale: Float, size: ContentRankingCardSize) { + findViewById(R.id.ll_content_ranking_label).layoutParams = LayoutParams((width * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply { + leftMargin = ((size.widthPx - (width * scale)) / 2f).roundToInt() + topMargin = (top * scale).roundToInt() + } + } + + private fun applyBlur(enabled: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + imageView().setRenderEffect( + if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null + ) + } + } + + private fun roundOutlineProvider() = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat()) + } + } + + private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt() + + private companion object { + const val SMALL_THRESHOLD_PX = 140 + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt new file mode 100644 index 00000000..7be430ad --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt @@ -0,0 +1,148 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import android.content.Context +import android.graphics.Outline +import android.graphics.RenderEffect +import android.graphics.Shader +import android.os.Build +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import kr.co.vividnext.sodalive.R +import kotlin.math.roundToInt + +class ContentRankingHorizontalCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private var rankGroup: View? = null + private var image: ImageView? = null + private var rankText: TextView? = null + private var deltaAmountText: TextView? = null + private var deltaIcon: ImageView? = null + private var titleText: TextView? = null + private var creatorText: TextView? = null + private var inaccessibleText: TextView? = null + private var currentItem: ContentRankingItem? = null + private var clickListener: ((ContentRankingItem) -> Unit)? = null + + override fun onFinishInflate() { + super.onFinishInflate() + rankGroup = findViewById(R.id.ll_content_ranking_rank_group) + image = findViewById(R.id.iv_content_ranking_image) + rankText = findViewById(R.id.tv_content_ranking_rank) + deltaAmountText = findViewById(R.id.tv_content_ranking_delta_amount) + deltaIcon = findViewById(R.id.iv_content_ranking_delta_icon) + titleText = findViewById(R.id.tv_content_ranking_title) + creatorText = findViewById(R.id.tv_content_ranking_creator) + inaccessibleText = findViewById(R.id.tv_content_ranking_inaccessible) + imageView().outlineProvider = roundOutlineProvider() + imageView().clipToOutline = true + } + + fun bind(item: ContentRankingItem) { + currentItem = item + requireNotNull(rankText).apply { + text = item.rank.toString() + applyContentRankingRankGradient() + } + bindDelta(item) + val inaccessibleMessage = context.getString(R.string.content_ranking_inaccessible_info) + requireNotNull(titleText).text = item.displayContentName(inaccessibleMessage) + requireNotNull(creatorText).text = item.displayCreatorName() + requireNotNull(inaccessibleText).text = inaccessibleMessage + titleText?.visibility = if (item.isInaccessible) View.GONE else View.VISIBLE + creatorText?.visibility = if (item.isInaccessible) View.GONE else View.VISIBLE + inaccessibleText?.visibility = if (item.isInaccessible) View.VISIBLE else View.GONE + applyAccessState(item) + } + + fun setCardSize(size: ContentRankingCardSize) { + layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply { + width = size.widthPx + height = size.heightPx + } + positionViews(size) + } + + fun imageView(): ImageView = requireNotNull(image) + + fun setOnContentClick(listener: ((ContentRankingItem) -> Unit)?) { + clickListener = listener + currentItem?.let(::applyAccessState) + } + + private fun bindDelta(item: ContentRankingItem) { + val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount) + applyDeltaContainer(presentation) + requireNotNull(deltaIcon).apply { + setImageResource(presentation.iconRes) + layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply { + width = presentation.iconWidthDp.dpToPx() + height = presentation.iconHeightDp.dpToPx() + marginStart = presentation.iconMarginStartDp.dpToPx() + } + } + requireNotNull(deltaAmountText).apply { + text = presentation.amountText + visibility = if (presentation.showAmount) View.VISIBLE else View.GONE + } + } + + private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) { + findViewById(R.id.ll_content_ranking_delta).apply { + setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0) + val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0 + setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom) + } + } + + private fun applyAccessState(item: ContentRankingItem) { + applyBlur(item.isInaccessible) + isClickable = item.isTouchable && clickListener != null + setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null) + } + + private fun positionViews(size: ContentRankingCardSize) { + val scale = size.widthPx / 374f + requireNotNull(rankGroup).layoutParams = LayoutParams((49 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply { + leftMargin = (14 * scale).roundToInt() + topMargin = (14 * scale).roundToInt() + } + requireNotNull(rankText).applyContentRankingRankGradient() + imageView().layoutParams = LayoutParams((80 * scale).roundToInt(), (80 * scale).roundToInt()).apply { + leftMargin = (77 * scale).roundToInt() + topMargin = (10 * scale).roundToInt() + } + findViewById(R.id.ll_content_ranking_label).layoutParams = LayoutParams((189 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply { + leftMargin = (171 * scale).roundToInt() + topMargin = (31 * scale).roundToInt() + } + requireNotNull(inaccessibleText).layoutParams = LayoutParams((189 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply { + leftMargin = (171 * scale).roundToInt() + topMargin = (38 * scale).roundToInt() + } + } + + private fun applyBlur(enabled: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + imageView().setRenderEffect( + if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null + ) + } + } + + private fun roundOutlineProvider() = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat()) + } + } + + private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt() +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt new file mode 100644 index 00000000..719319a7 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType + +data class ContentRankingItem( + val contentId: String, + val creatorId: String, + val rank: Int, + val previousRank: Int?, + val rankChangeType: RankingChangeType, + val rankChangeAmount: Int?, + val contentName: String, + val creatorName: String, + val imageUrl: String, + val isBlocked: Boolean +) { + init { + require(rank >= 1) { "rank must be greater than or equal to 1." } + } + + val isInaccessible: Boolean = isBlocked + val isTouchable: Boolean = !isBlocked + val contentNameMaxLength: Int = when (rank) { + 1 -> 16 + in 2..10 -> 8 + else -> 12 + } + + fun displayContentName(inaccessibleMessage: String): String = when { + !isBlocked -> contentName.ellipsizeByRank() + rank <= 10 -> "" + else -> inaccessibleMessage + } + + fun displayCreatorName(): String = if (isBlocked) "" else creatorName + + private fun String.ellipsizeByRank(): String { + if (length <= contentNameMaxLength) return this + return "${take(contentNameMaxLength)}..." + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt new file mode 100644 index 00000000..5f93e9fa --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt @@ -0,0 +1,159 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import android.content.Context +import android.graphics.Outline +import android.graphics.RenderEffect +import android.graphics.Shader +import android.os.Build +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import kr.co.vividnext.sodalive.R +import kotlin.math.roundToInt + +class ContentRankingLargeCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + private var backgroundImage: ImageView? = null + private var contentImage: ImageView? = null + private var deltaGroup: View? = null + private var rankText: TextView? = null + private var deltaAmountText: TextView? = null + private var deltaIcon: ImageView? = null + private var titleText: TextView? = null + private var creatorText: TextView? = null + private var currentItem: ContentRankingItem? = null + private var clickListener: ((ContentRankingItem) -> Unit)? = null + + override fun onFinishInflate() { + super.onFinishInflate() + backgroundImage = findViewById(R.id.iv_content_ranking_background) + contentImage = findViewById(R.id.iv_content_ranking_image) + deltaGroup = findViewById(R.id.ll_content_ranking_delta) + rankText = findViewById(R.id.tv_content_ranking_rank) + deltaAmountText = findViewById(R.id.tv_content_ranking_delta_amount) + deltaIcon = findViewById(R.id.iv_content_ranking_delta_icon) + titleText = findViewById(R.id.tv_content_ranking_title) + creatorText = findViewById(R.id.tv_content_ranking_creator) + clipToOutline = true + outlineProvider = roundOutlineProvider() + contentImageView().outlineProvider = roundOutlineProvider() + contentImageView().clipToOutline = true + } + + fun bind(item: ContentRankingItem) { + currentItem = item + requireNotNull(rankText).apply { + text = item.rank.toString() + applyContentRankingRankGradient() + } + bindDelta(item) + requireNotNull(titleText).apply { + text = item.displayContentName(context.getString(R.string.content_ranking_inaccessible_info)) + visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE + } + requireNotNull(creatorText).apply { + text = item.displayCreatorName() + visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE + } + applyAccessState(item) + } + + fun setCardSize(size: ContentRankingCardSize) { + layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply { + width = size.widthPx + height = size.heightPx + } + positionViews(size) + } + + fun imageView(): ImageView = contentImageView() + + fun backgroundImageView(): ImageView = requireNotNull(backgroundImage) + + fun setOnContentClick(listener: ((ContentRankingItem) -> Unit)?) { + clickListener = listener + currentItem?.let(::applyAccessState) + } + + private fun bindDelta(item: ContentRankingItem) { + val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount) + applyDeltaContainer(presentation) + requireNotNull(deltaIcon).apply { + setImageResource(presentation.iconRes) + layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply { + width = presentation.iconWidthDp.dpToPx() + height = presentation.iconHeightDp.dpToPx() + marginStart = presentation.iconMarginStartDp.dpToPx() + } + } + requireNotNull(deltaAmountText).apply { + text = presentation.amountText + visibility = if (presentation.showAmount) View.VISIBLE else View.GONE + } + } + + private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) { + requireNotNull(deltaGroup).apply { + setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0) + val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0 + setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom) + } + } + + private fun applyAccessState(item: ContentRankingItem) { + applyBlur(item.isInaccessible) + isClickable = item.isTouchable && clickListener != null + setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null) + } + + private fun positionViews(size: ContentRankingCardSize) { + val scale = size.widthPx / FIGMA_WIDTH.toFloat() + requireNotNull(rankText).layoutParams = LayoutParams((91 * scale).roundToInt(), (114 * scale).roundToInt()) + contentImageView().layoutParams = LayoutParams((155 * scale).roundToInt(), (154 * scale).roundToInt()).apply { + leftMargin = ((size.widthPx - (155 * scale)) / 2f).roundToInt() + topMargin = (14 * scale).roundToInt() + } + findViewById(R.id.ll_content_ranking_delta).layoutParams = LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + leftMargin = (20 * scale).roundToInt() + topMargin = (109 * scale).roundToInt() + } + findViewById(R.id.ll_content_ranking_label).layoutParams = LayoutParams((165 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply { + leftMargin = ((size.widthPx - (165 * scale)) / 2f).roundToInt() + topMargin = (182 * scale).roundToInt() + } + requireNotNull(rankText).applyContentRankingRankGradient() + } + + private fun applyBlur(enabled: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val effect = if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null + contentImageView().setRenderEffect(effect) + backgroundImageView().setRenderEffect(effect) + } + } + + private fun contentImageView(): ImageView = requireNotNull(contentImage) + + private fun roundOutlineProvider() = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat()) + } + } + + private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt() + + private companion object { + const val FIGMA_WIDTH = 374 + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculator.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculator.kt new file mode 100644 index 00000000..4a92a5db --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculator.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +object ContentRankingLayoutCalculator { + private const val LARGE_FIGMA_WIDTH = 374 + private const val LARGE_FIGMA_HEIGHT = 238 + private const val HORIZONTAL_FIGMA_WIDTH = 374 + private const val HORIZONTAL_FIGMA_HEIGHT = 100 + + fun calculate( + parentWidthPx: Int, + parentHorizontalPaddingPx: Int = 0, + horizontalGapPx: Int, + placement: ContentRankingPlacement + ): ContentRankingCardSize { + require(parentWidthPx > 0) { "parentWidthPx must be > 0." } + require(parentHorizontalPaddingPx >= 0) { "parentHorizontalPaddingPx must be >= 0." } + require(horizontalGapPx >= 0) { "horizontalGapPx must be >= 0." } + require(placement.itemsPerRow > 0) { "itemsPerRow must be > 0." } + + val availableWidth = parentWidthPx - parentHorizontalPaddingPx + require(availableWidth > 0) { "available width must be > 0." } + val totalGap = horizontalGapPx * (placement.itemsPerRow - 1) + val width = (availableWidth - totalGap) / placement.itemsPerRow + val height = when (placement.variant) { + ContentRankingCardVariant.Large -> (width * LARGE_FIGMA_HEIGHT) / LARGE_FIGMA_WIDTH + ContentRankingCardVariant.MediumGrid, + ContentRankingCardVariant.SmallGrid -> width + ContentRankingCardVariant.Horizontal -> (width * HORIZONTAL_FIGMA_HEIGHT) / HORIZONTAL_FIGMA_WIDTH + } + return ContentRankingCardSize(widthPx = width, heightPx = height) + } +} + +data class ContentRankingCardSize( + val widthPx: Int, + val heightPx: Int +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingMediumGridCardView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingMediumGridCardView.kt new file mode 100644 index 00000000..b92869d2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingMediumGridCardView.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import android.content.Context +import android.util.AttributeSet + +class ContentRankingMediumGridCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ContentRankingGridCardView(context, attrs, defStyleAttr) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacement.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacement.kt new file mode 100644 index 00000000..cacb54e3 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacement.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +data class ContentRankingPlacement( + val variant: ContentRankingCardVariant, + val itemsPerRow: Int +) { + companion object { + fun fromRank(rank: Int): ContentRankingPlacement { + require(rank >= 1) { "rank must be greater than or equal to 1." } + return when (rank) { + 1 -> ContentRankingPlacement(ContentRankingCardVariant.Large, itemsPerRow = 1) + in 2..7 -> ContentRankingPlacement(ContentRankingCardVariant.MediumGrid, itemsPerRow = 2) + in 8..10 -> ContentRankingPlacement(ContentRankingCardVariant.SmallGrid, itemsPerRow = 3) + else -> ContentRankingPlacement(ContentRankingCardVariant.Horizontal, itemsPerRow = 1) + } + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingRankGradient.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingRankGradient.kt new file mode 100644 index 00000000..e2a9656d --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingRankGradient.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Shader +import android.widget.TextView + +internal fun TextView.applyContentRankingRankGradient() { + post { + val gradientHeight = height.takeIf { it > 0 } ?: lineHeight + paint.shader = LinearGradient( + 0f, + 0f, + 0f, + gradientHeight.toFloat(), + Color.WHITE, + Color.parseColor("#EEEEEE"), + Shader.TileMode.CLAMP + ) + invalidate() + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingSmallGridCardView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingSmallGridCardView.kt new file mode 100644 index 00000000..7e3fedba --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingSmallGridCardView.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import android.content.Context +import android.util.AttributeSet + +class ContentRankingSmallGridCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ContentRankingGridCardView(context, attrs, defStyleAttr) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.kt deleted file mode 100644 index 905972b3..00000000 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.kt +++ /dev/null @@ -1,8 +0,0 @@ -package kr.co.vividnext.sodalive.v2.widget.creatorranking - -enum class CreatorRankingChangeType { - Increase, - Decrease, - Stay, - New -} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt index 60ffd38b..aaa812ea 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentation.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.widget.creatorranking import androidx.annotation.DrawableRes import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType data class CreatorRankingDeltaPresentation( @get:DrawableRes val iconRes: Int, @@ -14,10 +15,10 @@ data class CreatorRankingDeltaPresentation( ) { companion object { fun from( - type: CreatorRankingChangeType, + type: RankingChangeType, amount: Int ): CreatorRankingDeltaPresentation = when (type) { - CreatorRankingChangeType.Increase -> CreatorRankingDeltaPresentation( + RankingChangeType.Increase -> CreatorRankingDeltaPresentation( iconRes = R.drawable.ic_rank_caret_increase, showAmount = true, amountText = amount.toString(), @@ -26,7 +27,7 @@ data class CreatorRankingDeltaPresentation( iconHeightDp = CARET_ICON_SIZE_DP, iconMarginStartDp = CARET_ICON_MARGIN_START_DP ) - CreatorRankingChangeType.Decrease -> CreatorRankingDeltaPresentation( + RankingChangeType.Decrease -> CreatorRankingDeltaPresentation( iconRes = R.drawable.ic_rank_caret_decrease, showAmount = true, amountText = amount.toString(), @@ -35,7 +36,7 @@ data class CreatorRankingDeltaPresentation( iconHeightDp = CARET_ICON_SIZE_DP, iconMarginStartDp = CARET_ICON_MARGIN_START_DP ) - CreatorRankingChangeType.Stay -> CreatorRankingDeltaPresentation( + RankingChangeType.Stay -> CreatorRankingDeltaPresentation( iconRes = R.drawable.ic_rank_caret_stay, showAmount = false, amountText = null, @@ -44,7 +45,7 @@ data class CreatorRankingDeltaPresentation( iconHeightDp = CARET_ICON_SIZE_DP, iconMarginStartDp = CARET_ICON_MARGIN_START_DP ) - CreatorRankingChangeType.New -> CreatorRankingDeltaPresentation( + RankingChangeType.New -> CreatorRankingDeltaPresentation( iconRes = R.drawable.ic_rank_new, showAmount = false, amountText = null, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt index ea0d1c6e..9b2e01e6 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt @@ -1,10 +1,12 @@ package kr.co.vividnext.sodalive.v2.widget.creatorranking +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType + data class CreatorRankingItem( val creatorId: Long, val rank: Int, val previousRank: Int?, - val rankChangeType: CreatorRankingChangeType, + val rankChangeType: RankingChangeType, val rankChangeAmount: Int, val creatorName: String, val imageUrl: String, diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingChangeType.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingChangeType.kt new file mode 100644 index 00000000..1ad23866 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingChangeType.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.v2.widget.ranking + +enum class RankingChangeType { + Increase, + Decrease, + Stay, + New +} diff --git a/app/src/main/res/layout/view_content_ranking_grid_card_content.xml b/app/src/main/res/layout/view_content_ranking_grid_card_content.xml new file mode 100644 index 00000000..eacca7ea --- /dev/null +++ b/app/src/main/res/layout/view_content_ranking_grid_card_content.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_content_ranking_horizontal_card.xml b/app/src/main/res/layout/view_content_ranking_horizontal_card.xml new file mode 100644 index 00000000..3f1a157e --- /dev/null +++ b/app/src/main/res/layout/view_content_ranking_horizontal_card.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_content_ranking_large_card.xml b/app/src/main/res/layout/view_content_ranking_large_card.xml new file mode 100644 index 00000000..20696f35 --- /dev/null +++ b/app/src/main/res/layout/view_content_ranking_large_card.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_content_ranking_medium_grid_card.xml b/app/src/main/res/layout/view_content_ranking_medium_grid_card.xml new file mode 100644 index 00000000..c2f8fbfd --- /dev/null +++ b/app/src/main/res/layout/view_content_ranking_medium_grid_card.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/layout/view_content_ranking_small_grid_card.xml b/app/src/main/res/layout/view_content_ranking_small_grid_card.xml new file mode 100644 index 00000000..7d6bc9b1 --- /dev/null +++ b/app/src/main/res/layout/view_content_ranking_small_grid_card.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 69b2f11e..ef7b6e76 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1309,6 +1309,7 @@ The upload will continue even if you leave this page. Recommended content by tag Trending creators This information is not accessible. + This information is not accessible. 19 Recommended free content by channel Creator intro diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index af4c5ad2..cc174a83 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1307,6 +1307,7 @@ タグ別おすすめコンテンツ 人気急上昇 アクセスできない情報です。 + アクセスできない情報です。 R-18 チャンネル別おすすめ無料コンテンツ クリエイター紹介 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c48a9e93..009c0768 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1324,6 +1324,7 @@ 새로운 콘텐츠 ※ 인기 순위는 매주 업데이트됩니다. 접근할 수 없는 정보입니다. + 접근할 수 없는 정보입니다. 새로운 알람 diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentationTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentationTest.kt new file mode 100644 index 00000000..a393d2ff --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentationTest.kt @@ -0,0 +1,51 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContentRankingDeltaPresentationTest { + + @Test + fun `increase shows amount and increase caret`() { + val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Increase, amount = 4) + + assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes) + assertEquals("4", presentation.amountText) + assertTrue(presentation.showAmount) + assertFalse(presentation.replaceWithNewIcon) + } + + @Test + fun `decrease shows amount and decrease caret`() { + val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Decrease, amount = 2) + + assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes) + assertEquals("2", presentation.amountText) + assertTrue(presentation.showAmount) + assertFalse(presentation.replaceWithNewIcon) + } + + @Test + fun `stay shows only stay icon`() { + val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Stay, amount = 0) + + assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes) + assertEquals("", presentation.amountText) + assertFalse(presentation.showAmount) + assertFalse(presentation.replaceWithNewIcon) + } + + @Test + fun `new replaces rank num with new icon`() { + val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.New, amount = null) + + assertEquals(R.drawable.ic_rank_new, presentation.iconRes) + assertEquals("", presentation.amountText) + assertFalse(presentation.showAmount) + assertTrue(presentation.replaceWithNewIcon) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItemTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItemTest.kt new file mode 100644 index 00000000..49e65ac4 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItemTest.kt @@ -0,0 +1,92 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContentRankingItemTest { + + @Test + fun `blocked item is inaccessible`() { + val item = sampleItem(isBlocked = true) + + assertTrue(item.isInaccessible) + assertFalse(item.isTouchable) + } + + @Test + fun `accessible item is touchable`() { + val item = sampleItem() + + assertFalse(item.isInaccessible) + assertTrue(item.isTouchable) + } + + @Test + fun `top ten blocked item hides content and creator names`() { + val item = sampleItem(rank = 10, isBlocked = true) + + assertEquals("", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")) + assertEquals("", item.displayCreatorName()) + } + + @Test + fun `rank 11 blocked item shows inaccessible message as single line`() { + val item = sampleItem(rank = 11, isBlocked = true) + + assertEquals("접근할 수 없는 정보입니다.", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")) + assertEquals("", item.displayCreatorName()) + } + + @Test + fun `accessible item shows original names`() { + val item = sampleItem(contentName = "콘텐츠 이름", creatorName = "크리에이터 이름") + + assertEquals("콘텐츠 이름", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")) + assertEquals("크리에이터 이름", item.displayCreatorName()) + } + + @Test + fun `content title max length follows rank range`() { + assertEquals(16, sampleItem(rank = 1).contentNameMaxLength) + assertEquals(8, sampleItem(rank = 2).contentNameMaxLength) + assertEquals(8, sampleItem(rank = 10).contentNameMaxLength) + assertEquals(12, sampleItem(rank = 11).contentNameMaxLength) + } + + @Test + fun `accessible content name is truncated by rank range`() { + assertEquals( + "1234567890123456...", + sampleItem(rank = 1, contentName = "12345678901234567").displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.") + ) + assertEquals( + "12345678...", + sampleItem(rank = 2, contentName = "123456789").displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.") + ) + assertEquals( + "123456789012...", + sampleItem(rank = 11, contentName = "1234567890123").displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.") + ) + } + + private fun sampleItem( + rank: Int = 1, + contentName: String = "콘텐츠 이름", + creatorName: String = "크리에이터 이름", + isBlocked: Boolean = false + ) = ContentRankingItem( + contentId = "content-1", + creatorId = "creator-1", + rank = rank, + previousRank = 5, + rankChangeType = RankingChangeType.Increase, + rankChangeAmount = 4, + contentName = contentName, + creatorName = creatorName, + imageUrl = "https://example.com/image.png", + isBlocked = isBlocked + ) +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculatorTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculatorTest.kt new file mode 100644 index 00000000..d8c6d198 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculatorTest.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContentRankingLayoutCalculatorTest { + + @Test + fun `large item keeps figma large ratio`() { + val size = ContentRankingLayoutCalculator.calculate( + parentWidthPx = 374, + horizontalGapPx = 4, + placement = ContentRankingPlacement(ContentRankingCardVariant.Large, itemsPerRow = 1) + ) + + assertEquals(374, size.widthPx) + assertEquals(238, size.heightPx) + } + + @Test + fun `medium grid item width divides available width by items per row`() { + val size = ContentRankingLayoutCalculator.calculate( + parentWidthPx = 374, + horizontalGapPx = 4, + placement = ContentRankingPlacement(ContentRankingCardVariant.MediumGrid, itemsPerRow = 2) + ) + + assertEquals(185, size.widthPx) + assertEquals(185, size.heightPx) + } + + @Test + fun `small grid item subtracts two gaps`() { + val size = ContentRankingLayoutCalculator.calculate( + parentWidthPx = 374, + horizontalGapPx = 4, + placement = ContentRankingPlacement(ContentRankingCardVariant.SmallGrid, itemsPerRow = 3) + ) + + assertEquals(122, size.widthPx) + assertEquals(122, size.heightPx) + } + + @Test + fun `horizontal item keeps figma ratio`() { + val size = ContentRankingLayoutCalculator.calculate( + parentWidthPx = 374, + horizontalGapPx = 4, + placement = ContentRankingPlacement(ContentRankingCardVariant.Horizontal, itemsPerRow = 1) + ) + + assertEquals(374, size.widthPx) + assertEquals(100, size.heightPx) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacementTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacementTest.kt new file mode 100644 index 00000000..36cca09d --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacementTest.kt @@ -0,0 +1,50 @@ +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContentRankingPlacementTest { + + @Test + fun `rank 1 uses large variant and one item row`() { + val placement = ContentRankingPlacement.fromRank(1) + + assertEquals(ContentRankingCardVariant.Large, placement.variant) + assertEquals(1, placement.itemsPerRow) + } + + @Test + fun `rank 2 to 7 uses medium grid variant and two item row`() { + (2..7).forEach { rank -> + val placement = ContentRankingPlacement.fromRank(rank) + + assertEquals(ContentRankingCardVariant.MediumGrid, placement.variant) + assertEquals(2, placement.itemsPerRow) + } + } + + @Test + fun `rank 8 to 10 uses small grid variant and three item row`() { + (8..10).forEach { rank -> + val placement = ContentRankingPlacement.fromRank(rank) + + assertEquals(ContentRankingCardVariant.SmallGrid, placement.variant) + assertEquals(3, placement.itemsPerRow) + } + } + + @Test + fun `rank 11 or greater uses horizontal variant and one item row`() { + listOf(11, 12, 100).forEach { rank -> + val placement = ContentRankingPlacement.fromRank(rank) + + assertEquals(ContentRankingCardVariant.Horizontal, placement.variant) + assertEquals(1, placement.itemsPerRow) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `rank less than 1 is invalid`() { + ContentRankingPlacement.fromRank(0) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentationTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentationTest.kt index f50df5ca..f8fc3f8a 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentationTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingDeltaPresentationTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.widget.creatorranking import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -11,7 +12,7 @@ class CreatorRankingDeltaPresentationTest { @Test fun `increase shows caret and amount`() { - val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Increase, amount = 4) + val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Increase, amount = 4) assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes) assertTrue(presentation.showAmount) @@ -23,7 +24,7 @@ class CreatorRankingDeltaPresentationTest { @Test fun `decrease shows caret and amount`() { - val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Decrease, amount = 4) + val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Decrease, amount = 4) assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes) assertTrue(presentation.showAmount) @@ -32,7 +33,7 @@ class CreatorRankingDeltaPresentationTest { @Test fun `stay shows stay icon without amount`() { - val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Stay, amount = 0) + val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Stay, amount = 0) assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes) assertFalse(presentation.showAmount) @@ -41,7 +42,7 @@ class CreatorRankingDeltaPresentationTest { @Test fun `new shows new image without amount`() { - val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.New, amount = 0) + val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.New, amount = 0) assertEquals(R.drawable.ic_rank_new, presentation.iconRes) assertFalse(presentation.showAmount) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItemTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItemTest.kt index 0ad62a4f..2345158e 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItemTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItemTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.widget.creatorranking +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -48,7 +49,7 @@ class CreatorRankingItemTest { creatorId: Long = 1L, rank: Int = 1, previousRank: Int? = 5, - rankChangeType: CreatorRankingChangeType = CreatorRankingChangeType.Increase, + rankChangeType: RankingChangeType = RankingChangeType.Increase, rankChangeAmount: Int = 4, creatorName: String = "크리에이터 이름", imageUrl: String = "https://example.com/image.png", diff --git a/docs/plan-task/20260520_콘텐츠랭킹위젯컴포넌트.md b/docs/plan-task/20260520_콘텐츠랭킹위젯컴포넌트.md new file mode 100644 index 00000000..a05a0c5b --- /dev/null +++ b/docs/plan-task/20260520_콘텐츠랭킹위젯컴포넌트.md @@ -0,0 +1,665 @@ +# 콘텐츠 랭킹 위젯 컴포넌트 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Figma `20:3715`, `20:3718`, `20:3721`, `20:3724` 기준으로 순위 구간별 콘텐츠 랭킹 위젯 컴포넌트를 추가한다. + +**Architecture:** 랭킹 항목의 순위/차단 관계/텍스트 표시 상태를 순수 Kotlin contract로 먼저 분리하고, 순위 변동 타입은 크리에이터 랭킹과 콘텐츠 랭킹이 공유하는 공용 contract로 둔다. Android custom view와 RecyclerView adapter가 이 contract를 바인딩한다. 카드 UI는 `Large`, `MediumGrid`, `SmallGrid`, `Horizontal` 4개 variant로 나누며, 실제 크기는 row count와 부모 폭으로 계산한다. + +**Tech Stack:** Android XML Views, Kotlin custom View, RecyclerView, ViewBinding/resource merge, JUnit4 local unit test. + +--- + +## 작업 목표 +- 1위는 `Large` 전용 카드로 구현한다. +- 2위~7위는 `MediumGrid` 카드로 구현하고 한 줄 2개로 배치한다. +- 8위~10위는 `SmallGrid` 카드로 구현하고 한 줄 3개로 배치한다. +- 11위 이후는 `Horizontal` 카드로 구현한다. +- 콘텐츠명은 순위 구간별 글자 수 제한과 한 줄 말줄임을 적용한다. +- rank delta 상태에 따라 상승/하락/동일/신규 진입 UI를 표시한다. +- 차단 관계인 크리에이터의 콘텐츠는 이미지 블러, 정보 비노출 또는 대체문구, 터치 불가 상태로 표시한다. +- 이미지 크기는 고정하지 않고 row container 폭과 row count로 계산한다. + +## 파일 구조 +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt` + - 콘텐츠 랭킹 UI에 필요한 순수 데이터 모델과 차단 관계 상태 계산을 정의한다. +- Rename/Move: creator ranking에서 사용하는 change type -> `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingChangeType.kt` + - `Increase`, `Decrease`, `Stay`, `New` 순위 변동 타입을 크리에이터/콘텐츠 랭킹 공용 타입으로 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardVariant.kt` + - `Large`, `MediumGrid`, `SmallGrid`, `Horizontal` 카드 UI variant를 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacement.kt` + - rank 기준 variant와 row count를 함께 결정한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculator.kt` + - 부모 폭, horizontal gap, row count 기준으로 item width/height를 계산한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentation.kt` + - 공용 `RankingChangeType`별 아이콘과 숫자 표시 여부를 정의한다. `Stay`와 `New`는 숫자를 표시하지 않는다. +- Create: `app/src/main/res/layout/view_content_ranking_large_card.xml` + - 1위 전용 큰 카드 layout을 정의한다. +- Create: `app/src/main/res/layout/view_content_ranking_medium_grid_card.xml` + - 2위~7위 2열 카드 layout을 정의한다. +- Create: `app/src/main/res/layout/view_content_ranking_small_grid_card.xml` + - 8위~10위 3열 카드 layout을 정의한다. +- Create: `app/src/main/res/layout/view_content_ranking_horizontal_card.xml` + - 11위 이후 가로형 카드 layout을 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt` + - 1위 전용 rank, delta, 콘텐츠명, 크리에이터명, access state를 바인딩한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingMediumGridCardView.kt` + - 2위~7위 rank, delta, 콘텐츠명, 크리에이터명, access state를 바인딩한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingSmallGridCardView.kt` + - 8위~10위 rank, delta, 콘텐츠명, 크리에이터명, access state를 바인딩한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt` + - 11위 이후 가로형 카드의 rank, delta, 콘텐츠명, 크리에이터명, access state를 바인딩한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingAdapter.kt` + - rank별 viewType과 터치 가능 여부를 처리한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacementTest.kt` + - rank별 variant/row count 계약을 검증한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItemTest.kt` + - 차단 관계 상태와 표시 텍스트 정책을 검증한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculatorTest.kt` + - 부모 폭 기반 크기 계산을 검증한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentationTest.kt` + - 상승/하락/동일/신규 진입 표시 계약을 검증한다. +- Modify: `app/src/main/res/values/strings.xml` + - 접근 불가 대체문구 `접근할 수 없는 정보입니다.`를 추가한다. +- Modify: `app/src/main/res/values-en/strings.xml`, `app/src/main/res/values-ja/strings.xml` + - 기존 다국어 정책에 맞춰 접근 불가 대체문구를 추가한다. +- Add if missing: `app/src/main/res/drawable/ic_rank_caret_increase.xml`, `ic_rank_caret_decrease.xml`, `ic_rank_caret_stay.xml`, `ic_rank_new.xml` + - Figma 에셋이 프로젝트에 없으면 디자인 에셋을 추가한다. +- Modify: `docs/plan-task/20260520_콘텐츠랭킹위젯컴포넌트.md` + - 구현 중 체크박스와 검증 기록을 누적한다. + +## 구현 계획 + +### Task 1: 기존 리소스 및 유사 UI 확인 + +**Files:** +- Read: `docs/prd/20260520_콘텐츠랭킹위젯컴포넌트_prd.md` +- Read: `docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md` +- Read: `app/src/main/java/kr/co/vividnext/sodalive/common/image/BlurTransformation.kt` +- Read: `app/src/main/res/values/colors.xml` +- Read: `app/src/main/res/values/dimens.xml` +- Read: `app/src/main/res/values/typography.xml` + +- [x] **Step 1: 현재 랭킹/블러/에셋 사용처 확인** + +Run: `rg -n "ContentRanking|CreatorRanking|ic_rank_caret|ic_rank_new|BlurTransformation|rank" app/src/main app/src/test docs` + +Expected: 기존 랭킹 contract, blur 구현, rank 이미지 리소스 사용처를 확인한다. + +- [x] **Step 2: Figma 세부 컨텍스트 재확인** + +Run tools: +- `Figma_get_design_context(20:3715)` +- `Figma_get_design_context(20:3718)` +- `Figma_get_design_context(20:3721)` +- `Figma_get_design_context(20:3724)` + +Expected: typography, color, radius, spacing, icon asset name을 확인한다. 도구가 timeout이면 현재 PRD의 screenshot 기준으로 진행하고 검증 기록에 남긴다. + +- [x] **Step 3: Figma token을 구현 기준으로 정리** + +Expected token contract: +- 공통 카드 image radius: `radius_14` 또는 `14dp` +- 공통 dim gradient: top transparent, bottom black, opacity `50%`, transition start `64.423%` +- 공통 `rank-num`: background `gray_900` (`#202020`), radius `4dp`, horizontal padding `4dp`, gap `2dp` +- 공통 `rank-num` 숫자: Pretendard Variable Medium, `16sp`, line-height `1.45`, white +- 공통 caret icon: `14dp x 14dp` +- 순위 숫자: Pattaya Regular, white~`#EEEEEE` gradient, `0px 0px 4px rgba(0,0,0,0.48)` shadow +- `Large` title: Pretendard Variable Bold, `22sp`, line-height `1.45`, white +- `Large` creator: Pretendard Variable Regular, `12sp`, line-height normal, white +- `MediumGrid` title: Pretendard Variable Bold, `22sp`, line-height `1.45`, white +- `MediumGrid` creator: Pretendard Variable Regular, `12sp`, line-height normal, white +- `SmallGrid` title: Pretendard Variable Bold, `14sp`, line-height normal, white +- `SmallGrid` creator: Pretendard Variable Regular, `12sp`, line-height normal, white +- `Horizontal` title: Pretendard Variable Bold, `18sp`, line-height `1.45`, white +- `Horizontal` creator: Pretendard Variable Regular, `14sp`, line-height `1.45`, white + +### Task 2: Rank placement contract TDD + +**Files:** +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacementTest.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingCardVariant.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacement.kt` + +- [x] **Step 1: RED - rank별 variant와 row count 테스트 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContentRankingPlacementTest { + + @Test + fun `rank 1 uses large variant and one item row`() { + val placement = ContentRankingPlacement.fromRank(1) + + assertEquals(ContentRankingCardVariant.Large, placement.variant) + assertEquals(1, placement.itemsPerRow) + } + + @Test + fun `rank 2 to 7 uses medium grid variant and two item row`() { + (2..7).forEach { rank -> + val placement = ContentRankingPlacement.fromRank(rank) + + assertEquals(ContentRankingCardVariant.MediumGrid, placement.variant) + assertEquals(2, placement.itemsPerRow) + } + } + + @Test + fun `rank 8 to 10 uses small grid variant and three item row`() { + (8..10).forEach { rank -> + val placement = ContentRankingPlacement.fromRank(rank) + + assertEquals(ContentRankingCardVariant.SmallGrid, placement.variant) + assertEquals(3, placement.itemsPerRow) + } + } + + @Test + fun `rank 11 or greater uses horizontal variant and one item row`() { + listOf(11, 12, 100).forEach { rank -> + val placement = ContentRankingPlacement.fromRank(rank) + + assertEquals(ContentRankingCardVariant.Horizontal, placement.variant) + assertEquals(1, placement.itemsPerRow) + } + } + + @Test(expected = IllegalArgumentException::class) + fun `rank less than 1 is invalid`() { + ContentRankingPlacement.fromRank(0) + } +} +``` + +- [x] **Step 2: RED 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingPlacementTest"` + +Expected: `Unresolved reference 'ContentRankingPlacement'`로 실패한다. + +- [x] **Step 3: GREEN - 최소 placement contract 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.contentranking + +enum class ContentRankingCardVariant { + Large, + MediumGrid, + SmallGrid, + Horizontal +} +``` + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.contentranking + +data class ContentRankingPlacement( + val variant: ContentRankingCardVariant, + val itemsPerRow: Int +) { + companion object { + fun fromRank(rank: Int): ContentRankingPlacement { + require(rank >= 1) { "rank must be greater than or equal to 1." } + return when (rank) { + 1 -> ContentRankingPlacement(ContentRankingCardVariant.Large, itemsPerRow = 1) + in 2..7 -> ContentRankingPlacement(ContentRankingCardVariant.MediumGrid, itemsPerRow = 2) + in 8..10 -> ContentRankingPlacement(ContentRankingCardVariant.SmallGrid, itemsPerRow = 3) + else -> ContentRankingPlacement(ContentRankingCardVariant.Horizontal, itemsPerRow = 1) + } + } + } +} +``` + +- [x] **Step 4: GREEN 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingPlacementTest"` + +Expected: `BUILD SUCCESSFUL` + +### Task 3: Ranking item state contract TDD + +**Files:** +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItemTest.kt` +- Rename/Move: creator ranking에서 사용하는 change type -> `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingChangeType.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt` + +- [x] **Step 1: RED - 접근 가능/불가 및 텍스트 표시 정책 테스트 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContentRankingItemTest { + + @Test + fun `blocked item is inaccessible`() { + val item = sampleItem(isBlocked = true) + + assertTrue(item.isInaccessible) + assertFalse(item.isTouchable) + } + + @Test + fun `accessible item is touchable`() { + val item = sampleItem() + + assertFalse(item.isInaccessible) + assertTrue(item.isTouchable) + } + + @Test + fun `top ten blocked item hides content and creator names`() { + val item = sampleItem(rank = 10, isBlocked = true) + + assertEquals("", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")) + assertEquals("", item.displayCreatorName()) + } + + @Test + fun `rank 11 blocked item shows inaccessible message as single line`() { + val item = sampleItem(rank = 11, isBlocked = true) + + assertEquals("접근할 수 없는 정보입니다.", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")) + assertEquals("", item.displayCreatorName()) + } + + @Test + fun `accessible item shows original names`() { + val item = sampleItem(contentName = "콘텐츠 이름", creatorName = "크리에이터 이름") + + assertEquals("콘텐츠 이름", item.displayContentName(inaccessibleMessage = "접근할 수 없는 정보입니다.")) + assertEquals("크리에이터 이름", item.displayCreatorName()) + } + + @Test + fun `content title max length follows rank range`() { + assertEquals(16, sampleItem(rank = 1).contentNameMaxLength) + assertEquals(8, sampleItem(rank = 2).contentNameMaxLength) + assertEquals(8, sampleItem(rank = 10).contentNameMaxLength) + assertEquals(12, sampleItem(rank = 11).contentNameMaxLength) + } + + private fun sampleItem( + rank: Int = 1, + contentName: String = "콘텐츠 이름", + creatorName: String = "크리에이터 이름", + isBlocked: Boolean = false + ) = ContentRankingItem( + contentId = "content-1", + creatorId = "creator-1", + rank = rank, + previousRank = 5, + rankChangeType = RankingChangeType.Increase, + rankChangeAmount = 4, + contentName = contentName, + creatorName = creatorName, + imageUrl = "https://example.com/image.png", + isBlocked = isBlocked + ) +} +``` + +- [x] **Step 2: RED 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItemTest"` + +Expected: `Unresolved reference 'ContentRankingItem'`로 실패한다. + +- [x] **Step 3: GREEN - 공용 ranking change type과 최소 item contract 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.ranking + +enum class RankingChangeType { + Increase, + Decrease, + Stay, + New +} +``` + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType + +data class ContentRankingItem( + val contentId: String, + val creatorId: String, + val rank: Int, + val previousRank: Int?, + val rankChangeType: RankingChangeType, + val rankChangeAmount: Int?, + val contentName: String, + val creatorName: String, + val imageUrl: String, + val isBlocked: Boolean +) { + val isInaccessible: Boolean = isBlocked + val isTouchable: Boolean = !isBlocked + val contentNameMaxLength: Int = when (rank) { + 1 -> 16 + in 2..10 -> 8 + else -> 12 + } + + fun displayContentName(inaccessibleMessage: String): String = when { + !isBlocked -> contentName + rank <= 10 -> "" + else -> inaccessibleMessage + } + + fun displayCreatorName(): String = if (isBlocked) "" else creatorName +} +``` + +- [x] **Step 4: GREEN 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItemTest"` + +Expected: `BUILD SUCCESSFUL` + +### Task 4: Rank delta presentation contract TDD + +**Files:** +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentationTest.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentation.kt` + +- [x] **Step 1: RED - 변동 상태별 표시 테스트 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContentRankingDeltaPresentationTest { + + @Test + fun `increase shows amount and increase caret`() { + val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Increase, amount = 4) + + assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes) + assertEquals("4", presentation.amountText) + assertTrue(presentation.showAmount) + assertFalse(presentation.replaceWithNewIcon) + } + + @Test + fun `decrease shows amount and decrease caret`() { + val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Decrease, amount = 2) + + assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes) + assertEquals("2", presentation.amountText) + assertTrue(presentation.showAmount) + assertFalse(presentation.replaceWithNewIcon) + } + + @Test + fun `stay shows only stay icon`() { + val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.Stay, amount = 0) + + assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes) + assertEquals("", presentation.amountText) + assertFalse(presentation.showAmount) + assertFalse(presentation.replaceWithNewIcon) + } + + @Test + fun `new replaces rank num with new icon`() { + val presentation = ContentRankingDeltaPresentation.from(RankingChangeType.New, amount = null) + + assertEquals(R.drawable.ic_rank_new, presentation.iconRes) + assertEquals("", presentation.amountText) + assertFalse(presentation.showAmount) + assertTrue(presentation.replaceWithNewIcon) + } +} +``` + +- [x] **Step 2: RED 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingDeltaPresentationTest"` + +Expected: `Unresolved reference 'ContentRankingDeltaPresentation'` 또는 missing drawable reference로 실패한다. + +- [x] **Step 3: GREEN - delta presentation 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import androidx.annotation.DrawableRes +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType + +data class ContentRankingDeltaPresentation( + @DrawableRes val iconRes: Int, + val amountText: String, + val showAmount: Boolean, + val replaceWithNewIcon: Boolean +) { + companion object { + fun from(type: RankingChangeType, amount: Int?): ContentRankingDeltaPresentation = when (type) { + RankingChangeType.Increase -> ContentRankingDeltaPresentation( + iconRes = R.drawable.ic_rank_caret_increase, + amountText = requireNotNull(amount).toString(), + showAmount = true, + replaceWithNewIcon = false + ) + RankingChangeType.Decrease -> ContentRankingDeltaPresentation( + iconRes = R.drawable.ic_rank_caret_decrease, + amountText = requireNotNull(amount).toString(), + showAmount = true, + replaceWithNewIcon = false + ) + RankingChangeType.Stay -> ContentRankingDeltaPresentation( + iconRes = R.drawable.ic_rank_caret_stay, + amountText = "", + showAmount = false, + replaceWithNewIcon = false + ) + RankingChangeType.New -> ContentRankingDeltaPresentation( + iconRes = R.drawable.ic_rank_new, + amountText = "", + showAmount = false, + replaceWithNewIcon = true + ) + } + } +} +``` + +- [x] **Step 4: GREEN 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingDeltaPresentationTest"` + +Expected: `BUILD SUCCESSFUL` + +### Task 5: Layout calculator contract TDD + +**Files:** +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculatorTest.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLayoutCalculator.kt` + +- [x] **Step 1: RED - 부모 폭 기반 크기 계산 테스트 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.contentranking + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContentRankingLayoutCalculatorTest { + + @Test + fun `square item width divides available width by items per row`() { + val size = ContentRankingLayoutCalculator.calculateSquareItemSize( + parentWidthPx = 374, + horizontalGapPx = 4, + itemsPerRow = 2 + ) + + assertEquals(185, size.widthPx) + assertEquals(185, size.heightPx) + } + + @Test + fun `three column square item subtracts two gaps`() { + val size = ContentRankingLayoutCalculator.calculateSquareItemSize( + parentWidthPx = 374, + horizontalGapPx = 4, + itemsPerRow = 3 + ) + + assertEquals(122, size.widthPx) + assertEquals(122, size.heightPx) + } + + @Test + fun `horizontal item keeps figma ratio`() { + val size = ContentRankingLayoutCalculator.calculateHorizontalItemSize(parentWidthPx = 374) + + assertEquals(374, size.widthPx) + assertEquals(100, size.heightPx) + } +} +``` + +- [x] **Step 2: RED 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingLayoutCalculatorTest"` + +Expected: `Unresolved reference 'ContentRankingLayoutCalculator'`로 실패한다. + +- [x] **Step 3: GREEN - 최소 layout calculator 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.contentranking + +data class ContentRankingItemSize( + val widthPx: Int, + val heightPx: Int +) + +object ContentRankingLayoutCalculator { + fun calculateSquareItemSize(parentWidthPx: Int, horizontalGapPx: Int, itemsPerRow: Int): ContentRankingItemSize { + require(parentWidthPx > 0) { "parentWidthPx must be greater than 0." } + require(horizontalGapPx >= 0) { "horizontalGapPx must be greater than or equal to 0." } + require(itemsPerRow > 0) { "itemsPerRow must be greater than 0." } + val totalGap = horizontalGapPx * (itemsPerRow - 1) + val size = (parentWidthPx - totalGap) / itemsPerRow + return ContentRankingItemSize(widthPx = size, heightPx = size) + } + + fun calculateHorizontalItemSize(parentWidthPx: Int): ContentRankingItemSize { + require(parentWidthPx > 0) { "parentWidthPx must be greater than 0." } + val height = (parentWidthPx * 100f / 374f).toInt() + return ContentRankingItemSize(widthPx = parentWidthPx, heightPx = height) + } +} +``` + +- [x] **Step 4: GREEN 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingLayoutCalculatorTest"` + +Expected: `BUILD SUCCESSFUL` + +### Task 6: XML 카드 레이아웃과 custom view 추가 + +**Files:** +- Create: `app/src/main/res/layout/view_content_ranking_large_card.xml` +- Create: `app/src/main/res/layout/view_content_ranking_medium_grid_card.xml` +- Create: `app/src/main/res/layout/view_content_ranking_small_grid_card.xml` +- Create: `app/src/main/res/layout/view_content_ranking_horizontal_card.xml` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingMediumGridCardView.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingSmallGridCardView.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt` +- Modify: `app/src/main/res/values/strings.xml` +- Modify: `app/src/main/res/values-en/strings.xml` +- Modify: `app/src/main/res/values-ja/strings.xml` + +- [x] **Step 1: 접근 불가 문자열 추가** + +Expected: +- `strings.xml`: `접근할 수 없는 정보입니다.` +- `values-en/strings.xml`: 동일 의미의 영문 문자열을 추가한다. +- `values-ja/strings.xml`: 동일 의미의 일본어 문자열을 추가한다. + +- [x] **Step 2: 4개 XML layout 추가** + +Expected: +- 모든 title/creator `TextView`는 `android:maxLines="1"`, `android:ellipsize="end"`를 가진다. +- 1위~10위 layout은 이름 영역을 `gone`으로 전환해도 dim gradient view가 남도록 overlay와 label container를 분리한다. +- 11위 이후 layout은 접근 불가 상태에서 단일 `TextView`만 표시할 수 있도록 title/creator 영역과 inaccessible message 영역을 분리한다. +- 이미지 view는 `centerCrop`, radius `14dp`, blur 적용 가능 구조를 가진다. + +- [x] **Step 3: 4개 custom view 추가** + +Expected: +- 각 custom view는 `bind(item: ContentRankingItem)` API를 제공한다. +- 각 custom view는 `ContentRankingDeltaPresentation`을 사용해 `rank-num` 또는 `ic_rank_new`를 표시한다. +- 각 custom view는 `item.isBlocked`일 때 이미지 blur와 터치 불가 상태를 적용한다. +- 1위~10위 custom view는 차단 상태에서 콘텐츠명/크리에이터명 영역만 숨기고 gradient는 유지한다. +- 11위 이후 custom view는 차단 상태에서 `content_ranking_inaccessible_info`만 한 줄로 표시한다. + +- [x] **Step 4: resource merge 확인** + +Run: `./gradlew :app:assembleDebug` + +Expected: `BUILD SUCCESSFUL` + +### Task 7: Adapter 추가 및 통합 계약 검증 + +**Files:** +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingAdapter.kt` +- Modify: `docs/plan-task/20260520_콘텐츠랭킹위젯컴포넌트.md` + +- [x] **Step 1: Adapter 추가** + +Expected: +- `getItemViewType`은 `ContentRankingPlacement.fromRank(item.rank).variant`를 기준으로 view type을 반환한다. +- `onCreateViewHolder`는 4개 custom view 중 하나를 생성한다. +- `onBindViewHolder`는 item을 bind하고, `item.isTouchable`이 false이면 click listener를 제거한다. +- 외부 클릭 동작은 `onItemClick: (ContentRankingItem) -> Unit` 형태로 호출부에 위임한다. + +- [x] **Step 2: 단위 테스트 전체 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"` + +Expected: `BUILD SUCCESSFUL` + +- [x] **Step 3: 관련 빌드 실행** + +Run: `./gradlew :app:assembleDebug` + +Expected: `BUILD SUCCESSFUL` + +- [x] **Step 4: 계획 문서 검증 기록 누적** + +Expected: +- 실행한 명령, 결과, 실패 시 원인과 후속 조치를 이 문서 하단 `검증 기록`에 누적한다. + +--- + +## 검증 기록 +- 2026-05-20: 문서만 먼저 작성하는 요청이므로 구현/빌드/테스트는 실행하지 않았다. Figma `20:3715`, `20:3718`, `20:3721`, `20:3724`의 design context와 screenshot을 확인해 PRD 및 구현 계획에 반영했다. +- 2026-05-20: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingItemTest" --tests "kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingDeltaPresentationTest" --tests "kr.co.vividnext.sodalive.v2.widget.contentranking.*"`를 먼저 실행해 `RankingChangeType` 및 콘텐츠 랭킹 contract 미구현으로 실패하는 RED를 확인했다. +- 2026-05-20: 공용 `RankingChangeType`, 콘텐츠 랭킹 placement/item/delta/layout calculator, XML/custom view/adapter를 구현한 뒤 동일 단위 테스트 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. +- 2026-05-20: `./gradlew :app:assembleDebug`를 실행해 Android resource merge, Kotlin compile, debug APK assemble이 `BUILD SUCCESSFUL`임을 확인했다. Kotlin LSP는 현재 환경에 서버가 없어 `lsp_diagnostics`를 실행할 수 없었다. diff --git a/docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md b/docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md index 6cf3cb95..cdd81815 100644 --- a/docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md +++ b/docs/plan-task/20260520_크리에이터랭킹위젯컴포넌트.md @@ -21,8 +21,8 @@ ## 파일 구조 - 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` 순위 변동 타입을 정의한다. +- Rename/Move: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.kt` -> `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingChangeType.kt` + - `Increase`, `Decrease`, `Stay`, `New` 순위 변동 타입을 크리에이터/콘텐츠 랭킹 공용 타입으로 정의한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingCardVariant.kt` - `Large`, `Compact`, `Horizontal` 카드 UI variant를 정의한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingPlacement.kt` @@ -30,7 +30,7 @@ - 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`는 숫자를 표시하지 않는다. + - 공용 `RankingChangeType`별 아이콘과 숫자 표시 여부를 정의한다. `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` @@ -215,7 +215,7 @@ Expected: `BUILD SUCCESSFUL` **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` +- Rename/Move: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingChangeType.kt` -> `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/ranking/RankingChangeType.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/creatorranking/CreatorRankingItem.kt` - [x] **Step 1: RED - 접근 가능/불가 표시 정책 테스트 추가** @@ -223,6 +223,7 @@ Expected: `BUILD SUCCESSFUL` ```kotlin package kr.co.vividnext.sodalive.v2.widget.creatorranking +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -271,7 +272,7 @@ class CreatorRankingItemTest { creatorId: Long = 1L, rank: Int = 1, previousRank: Int? = 5, - rankChangeType: CreatorRankingChangeType = CreatorRankingChangeType.Increase, + rankChangeType: RankingChangeType = RankingChangeType.Increase, rankChangeAmount: Int = 4, creatorName: String = "크리에이터 이름", imageUrl: String = "https://example.com/image.png", @@ -298,9 +299,9 @@ Expected: `Unresolved reference 'CreatorRankingItem'`로 실패한다. - [x] **Step 3: GREEN - 순수 상태 모델 추가** ```kotlin -package kr.co.vividnext.sodalive.v2.widget.creatorranking +package kr.co.vividnext.sodalive.v2.widget.ranking -enum class CreatorRankingChangeType { +enum class RankingChangeType { Increase, Decrease, Stay, @@ -311,11 +312,13 @@ enum class CreatorRankingChangeType { ```kotlin package kr.co.vividnext.sodalive.v2.widget.creatorranking +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType + data class CreatorRankingItem( val creatorId: Long, val rank: Int, val previousRank: Int?, - val rankChangeType: CreatorRankingChangeType, + val rankChangeType: RankingChangeType, val rankChangeAmount: Int, val creatorName: String, val imageUrl: String, @@ -354,6 +357,7 @@ Expected: `BUILD SUCCESSFUL` package kr.co.vividnext.sodalive.v2.widget.creatorranking import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -364,7 +368,7 @@ class CreatorRankingDeltaPresentationTest { @Test fun `increase shows caret and amount`() { - val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Increase, amount = 4) + val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Increase, amount = 4) assertEquals(R.drawable.ic_rank_caret_increase, presentation.iconRes) assertTrue(presentation.showAmount) @@ -373,7 +377,7 @@ class CreatorRankingDeltaPresentationTest { @Test fun `decrease shows caret and amount`() { - val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Decrease, amount = 4) + val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Decrease, amount = 4) assertEquals(R.drawable.ic_rank_caret_decrease, presentation.iconRes) assertTrue(presentation.showAmount) @@ -382,7 +386,7 @@ class CreatorRankingDeltaPresentationTest { @Test fun `stay shows stay icon without amount`() { - val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.Stay, amount = 0) + val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.Stay, amount = 0) assertEquals(R.drawable.ic_rank_caret_stay, presentation.iconRes) assertFalse(presentation.showAmount) @@ -391,7 +395,7 @@ class CreatorRankingDeltaPresentationTest { @Test fun `new shows new image without amount`() { - val presentation = CreatorRankingDeltaPresentation.from(CreatorRankingChangeType.New, amount = 0) + val presentation = CreatorRankingDeltaPresentation.from(RankingChangeType.New, amount = 0) assertEquals(R.drawable.ic_rank_new, presentation.iconRes) assertFalse(presentation.showAmount) @@ -413,6 +417,7 @@ package kr.co.vividnext.sodalive.v2.widget.creatorranking import androidx.annotation.DrawableRes import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType data class CreatorRankingDeltaPresentation( @DrawableRes val iconRes: Int, @@ -420,23 +425,23 @@ data class CreatorRankingDeltaPresentation( val amountText: String? ) { companion object { - fun from(type: CreatorRankingChangeType, amount: Int): CreatorRankingDeltaPresentation = when (type) { - CreatorRankingChangeType.Increase -> CreatorRankingDeltaPresentation( + fun from(type: RankingChangeType, amount: Int): CreatorRankingDeltaPresentation = when (type) { + RankingChangeType.Increase -> CreatorRankingDeltaPresentation( iconRes = R.drawable.ic_rank_caret_increase, showAmount = true, amountText = amount.toString() ) - CreatorRankingChangeType.Decrease -> CreatorRankingDeltaPresentation( + RankingChangeType.Decrease -> CreatorRankingDeltaPresentation( iconRes = R.drawable.ic_rank_caret_decrease, showAmount = true, amountText = amount.toString() ) - CreatorRankingChangeType.Stay -> CreatorRankingDeltaPresentation( + RankingChangeType.Stay -> CreatorRankingDeltaPresentation( iconRes = R.drawable.ic_rank_caret_stay, showAmount = false, amountText = null ) - CreatorRankingChangeType.New -> CreatorRankingDeltaPresentation( + RankingChangeType.New -> CreatorRankingDeltaPresentation( iconRes = R.drawable.ic_rank_new, showAmount = false, amountText = null @@ -619,8 +624,8 @@ Required common API: Required behavior: - `CreatorRankingDeltaPresentation`을 사용해 rank delta icon과 amount 표시 여부를 결정한다. -- `CreatorRankingChangeType.Stay`이면 숫자 없이 `ic_rank_caret_stay`만 표시한다. -- `CreatorRankingChangeType.New`이면 `ic_rank_new`를 표시하고 rank delta 숫자는 숨긴다. +- `RankingChangeType.Stay`이면 숫자 없이 `ic_rank_caret_stay`만 표시한다. +- `RankingChangeType.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는 숨기지 않는다. @@ -794,7 +799,7 @@ Expected: 신규 layout의 ViewBinding 생성 파일이 출력된다. - 결과: - `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 표시를 유지했다. + - `RankingChangeType.New`는 pill 배경 없이 `36dp x 23dp` 아이콘으로 표시하고, caret 계열은 기존 `14dp x 14dp` pill 표시를 유지했다. - creator ranking 단위 테스트와 `:app:assembleDebug`는 모두 `BUILD SUCCESSFUL`로 통과했다. - Kotlin LSP는 현재 환경에 `.kt` 확장자 서버가 없어 `No LSP server configured for extension: .kt`로 진단을 수행할 수 없었다. - 보강 후 재리뷰에서 기존 Important 3건은 모두 해소됐고 새 Critical/Important 이슈는 없음을 확인했다. diff --git a/docs/prd/20260520_콘텐츠랭킹위젯컴포넌트_prd.md b/docs/prd/20260520_콘텐츠랭킹위젯컴포넌트_prd.md new file mode 100644 index 00000000..0bca2d30 --- /dev/null +++ b/docs/prd/20260520_콘텐츠랭킹위젯컴포넌트_prd.md @@ -0,0 +1,195 @@ +# PRD: 콘텐츠 랭킹 위젯 컴포넌트 + +## 1. Overview +Figma `20:3715`, `20:3718`, `20:3721`, `20:3724` 디자인을 기준으로 콘텐츠 랭킹을 순위 구간별 카드 형태로 표현하는 Android XML Views 기반 위젯 컴포넌트를 개발한다. + +--- + +## 2. Problem +- 콘텐츠 랭킹은 순위 구간에 따라 카드 UI와 한 줄 배치 개수가 달라져야 한다. +- 기기 폭이 하나로 고정되지 않으므로 Figma metadata size를 실제 이미지 크기로 고정하면 다양한 화면 폭에서 재사용하기 어렵다. +- 2위~7위와 8위~10위는 카드 UI와 텍스트 크기가 달라질 수 있어 순위 구간별 표시 contract를 명확히 해야 한다. +- 순위 변동, 신규 진입, 차단 관계, 터치 가능 여부가 함께 표시되어야 하므로 데이터와 UI 상태 계약을 분리해야 한다. + +--- + +## 3. Goals +- Figma 4개 노드 기준의 콘텐츠 랭킹 카드 variant와 row 배치 정책을 제공한다. +- 이미지 크기는 컴포넌트 내부에서 고정하지 않고 실제 사용하는 row container 폭과 row count에 맞춰 계산한다. +- 순위 구간별 한 줄 배치 규칙을 제공한다. + - 1위: 한 줄에 1개, Figma `20:3715`, 큰 콘텐츠 카드. + - 2위~7위: 한 줄에 2개, Figma `20:3718`, 2열 정사각형 카드. + - 8위~10위: 한 줄에 3개, Figma `20:3721`, 3열 정사각형 카드. + - 11위 이후: 가로형으로 한 줄에 1개, Figma `20:3724`, 가로형 카드. +- 콘텐츠명은 순위 구간별 글자 수 기준을 초과하면 한 줄 말줄임 처리한다. +- `rank-num` 영역은 이전 순위와 비교한 변동 상태를 표시한다. +- 차단 관계인 크리에이터의 콘텐츠는 이미지 블러, 정보 비노출 또는 대체문구, 터치 불가 상태로 표시한다. +- 기존 화면 일괄 적용은 구현 계획에서 별도 task로 제한하고, 컴포넌트 계약을 우선 고정한다. + +--- + +## 4. Non-Goals +- 이번 범위에서는 서버 API 설계나 응답 필드명을 확정하지 않는다. 필요한 클라이언트 데이터 계약만 문서화한다. +- 크리에이터 랭킹 위젯, 오디오 콘텐츠 카드, 콘텐츠 상세 화면, 검색/필터 UI는 변경하지 않는다. +- Compose 컴포넌트 또는 Compose Theme를 추가하지 않는다. +- Figma에 없는 skeleton loading, shimmer, pressed animation, 별도 badge, 광고 영역, 페이지네이션 UI를 추가하지 않는다. +- 차단/차단 해제 기능 자체를 새로 만들지 않는다. +- 내가 차단했는지, 나를 차단했는지를 UI에서 구분해 표시하지 않는다. +- 이미지 로딩 라이브러리 교체를 수행하지 않는다. + +--- + +## 5. Target Users +- 콘텐츠 랭킹 화면을 보는 앱 사용자. +- XML 레이아웃과 RecyclerView 기반 랭킹 UI를 구현/유지보수하는 Android 개발자. + +--- + +## 6. User Stories +- 사용자는 인기 콘텐츠의 상위 순위를 구간별 강조도 차이로 빠르게 확인하고 싶다. +- 사용자는 콘텐츠의 순위가 올랐는지, 내려갔는지, 유지됐는지, 신규 진입했는지 알고 싶다. +- 사용자는 차단 관계에 있는 크리에이터의 콘텐츠 정보가 노출되지 않기를 기대한다. +- 개발자는 순위 구간별 UI를 하나의 명확한 계약으로 바인딩하고 싶다. + +--- + +## 7. Core Features + +### Content Ranking Widget +콘텐츠 랭킹 목록을 순위 구간별 카드 variant와 행 배치 규칙으로 표시한다. + +#### Figma References +- Rank 1 large content card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3715&m=dev +- Rank 2~7 two-column content card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3718&m=dev +- Rank 8~10 three-column content card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3721&m=dev +- Rank 11+ horizontal content card: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3724&m=dev + +#### Variant and Row Requirements +| Rank range | Figma node | Row count | UI variant | Size policy | +| --- | --- | --- | --- | --- | +| 1 | `20:3715` | 한 줄에 1개 | `Large` | 실제 사용 영역 폭을 1등분하고 Figma 큰 카드 비율로 표시 | +| 2~7 | `20:3718` | 한 줄에 2개 | `MediumGrid` | 실제 사용 영역 폭을 2등분해 정사각형으로 표시 | +| 8~10 | `20:3721` | 한 줄에 3개 | `SmallGrid` | 실제 사용 영역 폭을 3등분해 정사각형으로 표시 | +| 11+ | `20:3724` | 한 줄에 1개 | `Horizontal` | 실제 사용 영역 폭을 1등분하고 Figma 가로형 비율로 표시 | + +#### Variant Details +- `Large`: 1위 전용 카드다. 배경 영역, 중앙 콘텐츠 이미지, 하단 콘텐츠명/크리에이터명, 순위 숫자, 순위 변동 표시를 포함한다. +- `MediumGrid`: 2위~7위 전용 정사각형 카드다. 2열 배치를 기준으로 콘텐츠명은 `22sp` bold 스타일을 사용한다. +- `SmallGrid`: 8위~10위 전용 정사각형 카드다. 3열 배치를 기준으로 콘텐츠명은 `14sp` bold 스타일을 사용한다. +- `Horizontal`: 11위 이후 전용 가로형 카드다. 좌측 순위/변동, 중앙 이미지, 우측 콘텐츠명/크리에이터명 영역을 가진다. +- Figma metadata size는 참고용 비율 확인에만 사용하고, 구현에서 고정 dp 크기로 사용하지 않는다. + +#### Text Requirements +- 모든 텍스트는 `maxLines=1`, `ellipsize=end`로 한 줄 말줄임 처리한다. +- 1위 콘텐츠명은 16자를 초과하면 말줄임 처리한다. +- 2위~10위 콘텐츠명은 8자를 초과하면 말줄임 처리한다. +- 11위 이후 콘텐츠명은 12자를 초과하면 말줄임 처리한다. +- 크리에이터명도 한 줄 제한을 유지하고, 실제 잘림은 레이아웃 폭과 `ellipsize=end`에 따른다. + +#### Figma Token Requirements +- 공통 카드 이미지 radius는 `radius_14` 또는 `14dp`를 사용한다. +- 공통 dim gradient는 위쪽 `rgba(0,0,0,0)`, 아래쪽 black, opacity `50%`, 전환 시작점 `64.423%` 기준으로 구현한다. +- 1위 카드에는 Figma 기준 배경 blur/dim 영역과 중앙 콘텐츠 이미지 영역을 함께 둔다. +- 공통 `rank-num` 배경은 `gray_900` (`#202020`), radius `4dp`, horizontal padding `4dp`, gap `2dp`를 사용한다. +- 공통 `rank-num` 숫자는 Pretendard Variable Medium, `16sp`, line-height `1.45`, color white를 사용한다. +- 공통 caret icon 크기는 `14dp x 14dp`를 기준으로 한다. +- 순위 숫자는 Pattaya Regular를 사용하고 white~`#EEEEEE` vertical gradient와 `0px 0px 4px rgba(0,0,0,0.48)` shadow를 적용한다. +- `Large` 콘텐츠명은 Pretendard Variable Bold, `22sp`, line-height `1.45`, color white를 기준으로 한다. +- `Large` 크리에이터명은 Pretendard Variable Regular, `12sp`, line-height normal, color white를 기준으로 한다. +- `MediumGrid` 콘텐츠명은 Pretendard Variable Bold, `22sp`, line-height `1.45`, color white를 기준으로 한다. +- `MediumGrid` 크리에이터명은 Pretendard Variable Regular, `12sp`, line-height normal, color white를 기준으로 한다. +- `SmallGrid` 콘텐츠명은 Pretendard Variable Bold, `14sp`, line-height normal, color white를 기준으로 한다. +- `SmallGrid` 크리에이터명은 Pretendard Variable Regular, `12sp`, line-height normal, color white를 기준으로 한다. +- `Horizontal` 콘텐츠명은 Pretendard Variable Bold, `18sp`, line-height `1.45`, color white를 기준으로 한다. +- `Horizontal` 크리에이터명은 Pretendard Variable Regular, `14sp`, line-height `1.45`, color white를 기준으로 한다. + +#### Rank Change Requirements +- 모든 variant는 현재 순위 숫자를 표시한다. +- `rank-num` 영역은 순위 변동 상태를 표시한다. + - 순위 상승: `ic_rank_caret_increase`와 변동 숫자를 표시한다. + - 순위 하락: `ic_rank_caret_decrease`와 변동 숫자를 표시한다. + - 순위 동일: 숫자 없이 `ic_rank_caret_stay` 아이콘만 표시한다. + - 신규 진입: `rank-num` 대신 `ic_rank_new` 이미지를 표시한다. +- 신규 진입이 아니고 순위 동일이 아닌 경우 `rank-num`에는 이전 순위 대비 변동 숫자를 표시한다. + +#### Blocked Creator Requirements +- 내가 차단했거나 나를 차단한 크리에이터는 하나의 차단 관계 상태로만 전달받는다. +- 차단 관계 상태에서는 콘텐츠 이미지를 블러 처리한다. +- 차단 관계 상태의 1위~10위 카드는 콘텐츠명과 크리에이터 이름을 표시하지 않는다. +- 차단 관계 상태의 1위~10위 카드는 이름 영역을 숨겨도 하단 dim gradient 영역은 유지한다. +- 차단 관계 상태의 11위 이후 가로형 카드는 콘텐츠명과 크리에이터 이름 대신 `접근할 수 없는 정보입니다.` 한 줄만 표시한다. +- 차단 관계 상태의 카드는 터치할 수 없다. +- 접근 가능 상태의 카드는 터치할 수 있고, 터치 시 호출부가 콘텐츠 상세 이동 등 후속 동작을 처리한다. + +#### Data Contract Requirements +- 최소 데이터 계약은 다음 정보를 포함해야 한다. + - `contentId`: 콘텐츠 식별자. + - `creatorId`: 크리에이터 식별자. + - `rank`: 현재 순위. 1부터 시작한다. + - `previousRank`: 이전 순위. 신규 진입이면 null 허용. + - `rankChangeType`: `increase`, `decrease`, `stay`, `new` 중 하나. + - `rankChangeAmount`: 신규 진입이 아닌 경우 표시할 변동 숫자. + - `contentName`: 콘텐츠명. + - `creatorName`: 크리에이터 이름. + - `imageUrl`: 콘텐츠 이미지 URL. + - `isBlocked`: 내가 차단했거나 나를 차단한 차단 관계 여부. +- UI는 `isBlocked`만 사용하고 차단 방향은 구분하지 않는다. +- 순위 변동 타입은 콘텐츠 랭킹 전용 타입을 새로 만들지 않고, 크리에이터 랭킹에서 사용하는 변동 타입을 공용 이름으로 변경해 함께 사용한다. + +#### Edge Cases +- 랭킹 데이터가 0개이면 위젯 영역은 표시하지 않거나 호출부의 empty 정책을 따른다. +- 랭킹 데이터가 1~10개이면 존재하는 순위까지만 구간 규칙을 적용한다. +- `rank`가 누락되거나 1보다 작으면 호출부 데이터 오류로 간주하고 해당 항목을 표시하지 않는다. +- `rankChangeType`이 `new`이면 `previousRank`와 `rankChangeAmount`가 없어도 된다. +- `rankChangeType`이 `stay`이면 `rankChangeAmount`가 있어도 숫자를 표시하지 않는다. +- `rankChangeType`이 `increase` 또는 `decrease`인데 `rankChangeAmount`가 없으면 구현 단계에서 데이터 검증 정책을 정한다. +- 이미지 로딩 실패 시 placeholder 정책은 기존 이미지 로딩 계층 또는 호출 화면 정책을 따른다. + +--- + +## 8. UX / UI Expectations +- 전체 위젯은 어두운 배경 위에서 사용하는 것을 전제로 한다. +- 카드 이미지는 rounded corner를 가진다. +- 카드 이미지는 공통 dim gradient를 가진다. 차단 관계 상태에서 이름을 숨기더라도 1위~10위의 gradient overlay는 유지한다. +- 1위 카드는 배경 영역과 중앙 콘텐츠 이미지가 분리된 형태를 유지한다. +- 2위~7위와 8위~10위는 각각 2열/3열 배치에 맞춰 같은 데이터 계약을 다른 variant로 표시한다. +- 11위 이후 카드는 좌측 순위, 중앙 이미지, 우측 텍스트 영역을 가진다. +- 이미지 크기는 고정 dp로 박지 않고 row container 폭에서 계산한다. +- 정사각형 variant는 계산된 카드 폭과 동일한 높이로 표시한다. +- 가로형 variant는 부모 폭을 채우고 Figma 가로형 비율에 맞는 높이를 유지한다. +- 차단 관계 상태는 사용자가 상세로 들어갈 수 없음을 시각적으로 알 수 있어야 한다. + +--- + +## 9. Technical Constraints +- 현재 프로젝트는 Android XML Views + ViewBinding + RecyclerView 기반이므로 XML 레이아웃과 Kotlin custom view/adapter 패턴을 우선한다. +- 신규 Kotlin 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다. +- 재사용 가능한 위젯은 `kr.co.vividnext.sodalive.v2.widget.contentranking` 또는 기능 범위에 맞는 하위 패키지에 둔다. +- 기존 크리에이터 랭킹 위젯 문서는 참고 대상으로만 사용하고, 콘텐츠 랭킹 contract는 별도 파일로 분리한다. +- 크리에이터 랭킹에서 사용 중인 순위 변동 타입은 `RankingChangeType`처럼 랭킹 공용 이름으로 변경하고, 크리에이터 랭킹과 콘텐츠 랭킹이 동일 타입을 참조하도록 한다. +- 기존 프로젝트의 이미지 로딩 방식이 화면별로 Glide/Coil을 함께 사용하므로, 컴포넌트 내부에 특정 이미지 로더를 강제하지 않는 API를 우선한다. +- 차단 관계 이미지 블러는 기존 `kr.co.vividnext.sodalive.common.image.BlurTransformation` 등 기존 blur 구현의 재사용 가능성을 먼저 검토한다. +- `ic_rank_caret_increase`, `ic_rank_caret_decrease`, `ic_rank_caret_stay`, `ic_rank_new` 리소스가 없으면 구현 단계에서 디자인 에셋 추가가 필요하다. + +--- + +## 10. Metrics +- 순위 구간별 row count가 요구사항과 일치한다. +- 1위는 `Large`, 2위~7위는 `MediumGrid`, 8위~10위는 `SmallGrid`, 11위 이후는 `Horizontal` variant로 바인딩된다. +- 콘텐츠명은 순위 구간별 글자 수 기준과 한 줄 말줄임 계약을 만족한다. +- `rankChangeType`별 아이콘/숫자 표시가 문서와 일치한다. +- `stay` 상태에서는 숫자 없이 `ic_rank_caret_stay`만 표시된다. +- `new` 상태에서는 `rank-num` 대신 `ic_rank_new`가 표시된다. +- 차단 관계 상태에서 이미지 블러, 이름 비노출/대체문구, 터치 불가가 모두 적용된다. +- 차단 관계 상태에서 1위~10위 카드의 gradient overlay가 유지된다. +- 이미지 크기가 고정 dp가 아닌 부모 폭과 row count 기반으로 계산된다. +- 관련 unit test와 Android resource merge/build가 성공한다. + +--- + +## 11. Open Questions +- 서버 응답에 이전 순위, 신규 진입 여부, 차단 관계 여부가 이미 포함되는지 확인이 필요하다. 없으면 API/DTO 확장이 별도 백엔드 협의 항목이다. +- Figma `get_design_context` 확인 결과 typography/color/radius 토큰은 본 문서의 `Figma Token Requirements`에 반영했다. +- 콘텐츠명 글자 수 제한은 사용자 요구사항에 따라 1위 16자, 2위~10위 8자, 11위 이후 12자로 확정한다. +- 차단 관계 상태에서 1위~10위 카드의 이름 영역을 숨길 때 gradient 영역은 유지하는 것으로 확정한다. +- 순위 변동 타입은 크리에이터 랭킹과 콘텐츠 랭킹이 같은 데이터이므로, 구현 시 기존 크리에이터 랭킹 타입을 공용 `RankingChangeType`으로 rename해 재사용하는 것으로 확정한다. diff --git a/docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md b/docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md index d52f0dd2..2b750841 100644 --- a/docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md +++ b/docs/prd/20260520_크리에이터랭킹위젯컴포넌트_prd.md @@ -122,6 +122,7 @@ Figma `20:3702`, `20:3709`, `20:3711`, `20:3713` 디자인을 기준으로 크 - `imageUrl`: 카드 이미지 URL. - `isBlocked`: 내가 차단했거나 나를 차단한 차단 관계 여부. - UI는 `isBlocked`만 사용하고 차단 방향은 구분하지 않는다. +- 순위 변동 타입은 크리에이터 랭킹 전용 타입이 아니라 콘텐츠 랭킹과 공유하는 공용 `RankingChangeType`을 사용한다. #### Edge Cases - 랭킹 데이터가 0개이면 위젯 영역은 표시하지 않거나 호출부의 empty 정책을 따른다. @@ -156,6 +157,7 @@ Figma `20:3702`, `20:3709`, `20:3711`, `20:3713` 디자인을 기준으로 크 - 기존 프로젝트의 이미지 로딩 방식이 화면별로 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` 리소스가 없으면 구현 단계에서 디자인 에셋 추가가 필요하다. +- 기존 `CreatorRankingChangeType`은 `RankingChangeType`으로 rename/move해 크리에이터 랭킹과 콘텐츠 랭킹이 같은 타입을 참조하도록 한다. --- @@ -176,3 +178,4 @@ Figma `20:3702`, `20:3709`, `20:3711`, `20:3713` 디자인을 기준으로 크 - Figma `get_design_context` 재확인 결과 typography/color/radius 토큰은 본 문서의 `Figma Token Requirements`에 반영했다. - `rankChangeAmount`가 순위 동일일 때는 숫자 없이 `ic_rank_caret_stay` 아이콘만 표시하는 것으로 확정했다. - 차단 관계 상태에서 1위~10위 카드의 이름 영역을 숨길 때 gradient 영역은 유지하는 것으로 확정했다. +- 순위 변동 타입은 콘텐츠 랭킹과 같은 데이터이므로 공용 `RankingChangeType`으로 사용하는 것으로 확정했다.