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

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

View File

@@ -0,0 +1,170 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl
import kotlin.math.roundToInt
class ContentRankingAdapter(
private val onClickItem: (ContentRankingItem) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val items = mutableListOf<ContentRankingItem>()
override fun getItemViewType(position: Int): Int = when (ContentRankingPlacement.fromRank(items[position].rank).variant) {
ContentRankingCardVariant.Large -> VIEW_TYPE_LARGE
ContentRankingCardVariant.MediumGrid -> VIEW_TYPE_MEDIUM_GRID
ContentRankingCardVariant.SmallGrid -> VIEW_TYPE_SMALL_GRID
ContentRankingCardVariant.Horizontal -> VIEW_TYPE_HORIZONTAL
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_LARGE -> LargeViewHolder(
inflater.inflate(R.layout.view_content_ranking_large_card, parent, false) as ContentRankingLargeCardView,
parent
)
VIEW_TYPE_MEDIUM_GRID -> MediumGridViewHolder(
inflater.inflate(R.layout.view_content_ranking_medium_grid_card, parent, false) as ContentRankingMediumGridCardView,
parent
)
VIEW_TYPE_SMALL_GRID -> SmallGridViewHolder(
inflater.inflate(R.layout.view_content_ranking_small_grid_card, parent, false) as ContentRankingSmallGridCardView,
parent
)
VIEW_TYPE_HORIZONTAL -> HorizontalViewHolder(
inflater.inflate(R.layout.view_content_ranking_horizontal_card, parent, false) as ContentRankingHorizontalCardView,
parent
)
else -> error("Unknown viewType: $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = items[position]
when (holder) {
is LargeViewHolder -> holder.bind(item)
is MediumGridViewHolder -> holder.bind(item)
is SmallGridViewHolder -> holder.bind(item)
is HorizontalViewHolder -> holder.bind(item)
}
}
override fun getItemCount(): Int = items.size
fun submitItems(items: List<ContentRankingItem>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
private inner class LargeViewHolder(
private val view: ContentRankingLargeCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: ContentRankingItem) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadContentImage(item)
view.backgroundImageView().loadContentImage(item)
view.bind(item)
view.setOnContentClick(onClickItem)
}
}
private inner class MediumGridViewHolder(
private val view: ContentRankingMediumGridCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: ContentRankingItem) {
bindGrid(view, item, parent)
}
}
private inner class SmallGridViewHolder(
private val view: ContentRankingSmallGridCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: ContentRankingItem) {
bindGrid(view, item, parent)
}
}
private inner class HorizontalViewHolder(
private val view: ContentRankingHorizontalCardView,
private val parent: ViewGroup
) : RecyclerView.ViewHolder(view) {
fun bind(item: ContentRankingItem) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadContentImage(item)
view.bind(item)
view.setOnContentClick(onClickItem)
}
}
private fun bindGrid(
view: ContentRankingGridCardView,
item: ContentRankingItem,
parent: ViewGroup
) {
val size = calculateSize(item, parent)
view.setCardSize(size)
view.imageView().loadContentImage(item)
view.bind(item)
view.setOnContentClick(onClickItem)
}
private fun ImageView.loadContentImage(item: ContentRankingItem) {
loadUrl(item.imageUrl) {
val blurTransformations = ContentRankingBlur.transformations(context, item.isInaccessible)
if (blurTransformations.isNotEmpty()) {
transformations(blurTransformations)
}
}
}
private fun calculateSize(
item: ContentRankingItem,
parent: ViewGroup
): ContentRankingCardSize {
val parentWidth = parent.width.takeIf { it > 0 } ?: parent.resources.displayMetrics.widthPixels
return ContentRankingLayoutCalculator.calculate(
parentWidthPx = parentWidth,
parentHorizontalPaddingPx = parent.paddingLeft + parent.paddingRight,
horizontalGapPx = HORIZONTAL_GAP_DP.dpToPx(parent),
placement = ContentRankingPlacement.fromRank(item.rank)
)
}
private fun Int.dpToPx(parent: ViewGroup): Int = (this * parent.resources.displayMetrics.density).roundToInt()
companion object {
const val GRID_SPAN_COUNT = 6
fun createGridLayoutManager(context: Context): GridLayoutManager = GridLayoutManager(context, GRID_SPAN_COUNT).apply {
spanSizeLookup = createSpanSizeLookup()
}
fun createSpanSizeLookup(): GridLayoutManager.SpanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int = when (ContentRankingPlacement.fromRank(position + 1).itemsPerRow) {
1 -> GRID_SPAN_COUNT
2 -> GRID_SPAN_COUNT / 2
3 -> GRID_SPAN_COUNT / 3
else -> GRID_SPAN_COUNT
}
}
private const val VIEW_TYPE_LARGE = 1
private const val VIEW_TYPE_MEDIUM_GRID = 2
private const val VIEW_TYPE_SMALL_GRID = 3
private const val VIEW_TYPE_HORIZONTAL = 4
private const val HORIZONTAL_GAP_DP = 4
}
}

View File

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

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
enum class ContentRankingCardVariant {
Large,
MediumGrid,
SmallGrid,
Horizontal
}

View File

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

View File

@@ -0,0 +1,178 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import android.content.Context
import android.graphics.Outline
import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
open class ContentRankingGridCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var image: ImageView? = null
private var deltaGroup: View? = null
private var rankText: TextView? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var titleText: TextView? = null
private var creatorText: TextView? = null
private var currentItem: ContentRankingItem? = null
private var clickListener: ((ContentRankingItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
image = findViewById(R.id.iv_content_ranking_image)
deltaGroup = findViewById(R.id.ll_content_ranking_delta)
rankText = findViewById(R.id.tv_content_ranking_rank)
deltaAmountText = findViewById(R.id.tv_content_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_content_ranking_delta_icon)
titleText = findViewById(R.id.tv_content_ranking_title)
creatorText = findViewById(R.id.tv_content_ranking_creator)
clipToOutline = true
outlineProvider = roundOutlineProvider()
imageView().outlineProvider = roundOutlineProvider()
imageView().clipToOutline = true
}
fun bind(item: ContentRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyContentRankingRankGradient()
}
bindDelta(item)
requireNotNull(titleText).apply {
text = item.displayContentName(context.getString(R.string.content_ranking_inaccessible_info))
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
requireNotNull(creatorText).apply {
text = item.displayCreatorName()
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
applyAccessState(item)
}
fun setCardSize(size: ContentRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
}
positionViews(size)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnContentClick(listener: ((ContentRankingItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: ContentRankingItem) {
val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
applyDeltaContainer(presentation)
requireNotNull(deltaIcon).apply {
setImageResource(presentation.iconRes)
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
width = presentation.iconWidthDp.dpToPx()
height = presentation.iconHeightDp.dpToPx()
marginStart = presentation.iconMarginStartDp.dpToPx()
}
}
requireNotNull(deltaAmountText).apply {
text = presentation.amountText
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
}
}
private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) {
requireNotNull(deltaGroup).apply {
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
}
}
private fun applyAccessState(item: ContentRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: ContentRankingCardSize) {
if (size.widthPx <= SMALL_THRESHOLD_PX) positionSmall(size) else positionMedium(size)
requireNotNull(rankText).applyContentRankingRankGradient()
}
private fun positionMedium(size: ContentRankingCardSize) {
val scale = size.widthPx / 185f
requireNotNull(rankText).apply {
textSize = 54f
layoutParams = LayoutParams((55 * scale).roundToInt(), (75 * scale).roundToInt())
}
requireNotNull(titleText).textSize = 22f
placeDelta(left = 10, top = 70, scale = scale)
placeLabel(width = 165, top = 136, scale = scale, size = size)
}
private fun positionSmall(size: ContentRankingCardSize) {
val scale = size.widthPx / 122f
requireNotNull(rankText).apply {
textSize = 40f
layoutParams = LayoutParams((42 * scale).roundToInt(), (56 * scale).roundToInt()).apply {
leftMargin = (8 * scale).roundToInt()
}
}
requireNotNull(titleText).textSize = 14f
placeDelta(left = 10, top = 49, scale = scale)
placeLabel(width = 102, top = 81, scale = scale, size = size)
}
private fun placeDelta(left: Int, top: Int, scale: Float) {
requireNotNull(deltaGroup).layoutParams = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (left * scale).roundToInt()
topMargin = (top * scale).roundToInt()
}
}
private fun placeLabel(width: Int, top: Int, scale: Float, size: ContentRankingCardSize) {
findViewById<View>(R.id.ll_content_ranking_label).layoutParams = LayoutParams((width * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = ((size.widthPx - (width * scale)) / 2f).roundToInt()
topMargin = (top * scale).roundToInt()
}
}
private fun applyBlur(enabled: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
imageView().setRenderEffect(
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
)
}
}
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
}
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
private companion object {
const val SMALL_THRESHOLD_PX = 140
}
}

View File

@@ -0,0 +1,148 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import android.content.Context
import android.graphics.Outline
import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class ContentRankingHorizontalCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var rankGroup: View? = null
private var image: ImageView? = null
private var rankText: TextView? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var titleText: TextView? = null
private var creatorText: TextView? = null
private var inaccessibleText: TextView? = null
private var currentItem: ContentRankingItem? = null
private var clickListener: ((ContentRankingItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
rankGroup = findViewById(R.id.ll_content_ranking_rank_group)
image = findViewById(R.id.iv_content_ranking_image)
rankText = findViewById(R.id.tv_content_ranking_rank)
deltaAmountText = findViewById(R.id.tv_content_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_content_ranking_delta_icon)
titleText = findViewById(R.id.tv_content_ranking_title)
creatorText = findViewById(R.id.tv_content_ranking_creator)
inaccessibleText = findViewById(R.id.tv_content_ranking_inaccessible)
imageView().outlineProvider = roundOutlineProvider()
imageView().clipToOutline = true
}
fun bind(item: ContentRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyContentRankingRankGradient()
}
bindDelta(item)
val inaccessibleMessage = context.getString(R.string.content_ranking_inaccessible_info)
requireNotNull(titleText).text = item.displayContentName(inaccessibleMessage)
requireNotNull(creatorText).text = item.displayCreatorName()
requireNotNull(inaccessibleText).text = inaccessibleMessage
titleText?.visibility = if (item.isInaccessible) View.GONE else View.VISIBLE
creatorText?.visibility = if (item.isInaccessible) View.GONE else View.VISIBLE
inaccessibleText?.visibility = if (item.isInaccessible) View.VISIBLE else View.GONE
applyAccessState(item)
}
fun setCardSize(size: ContentRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
}
positionViews(size)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnContentClick(listener: ((ContentRankingItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: ContentRankingItem) {
val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
applyDeltaContainer(presentation)
requireNotNull(deltaIcon).apply {
setImageResource(presentation.iconRes)
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
width = presentation.iconWidthDp.dpToPx()
height = presentation.iconHeightDp.dpToPx()
marginStart = presentation.iconMarginStartDp.dpToPx()
}
}
requireNotNull(deltaAmountText).apply {
text = presentation.amountText
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
}
}
private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) {
findViewById<View>(R.id.ll_content_ranking_delta).apply {
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
}
}
private fun applyAccessState(item: ContentRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: ContentRankingCardSize) {
val scale = size.widthPx / 374f
requireNotNull(rankGroup).layoutParams = LayoutParams((49 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = (14 * scale).roundToInt()
topMargin = (14 * scale).roundToInt()
}
requireNotNull(rankText).applyContentRankingRankGradient()
imageView().layoutParams = LayoutParams((80 * scale).roundToInt(), (80 * scale).roundToInt()).apply {
leftMargin = (77 * scale).roundToInt()
topMargin = (10 * scale).roundToInt()
}
findViewById<View>(R.id.ll_content_ranking_label).layoutParams = LayoutParams((189 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = (171 * scale).roundToInt()
topMargin = (31 * scale).roundToInt()
}
requireNotNull(inaccessibleText).layoutParams = LayoutParams((189 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = (171 * scale).roundToInt()
topMargin = (38 * scale).roundToInt()
}
}
private fun applyBlur(enabled: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
imageView().setRenderEffect(
if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
)
}
}
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
}
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
}

View File

@@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import kr.co.vividnext.sodalive.v2.widget.ranking.RankingChangeType
data class ContentRankingItem(
val contentId: String,
val creatorId: String,
val rank: Int,
val previousRank: Int?,
val rankChangeType: RankingChangeType,
val rankChangeAmount: Int?,
val contentName: String,
val creatorName: String,
val imageUrl: String,
val isBlocked: Boolean
) {
init {
require(rank >= 1) { "rank must be greater than or equal to 1." }
}
val isInaccessible: Boolean = isBlocked
val isTouchable: Boolean = !isBlocked
val contentNameMaxLength: Int = when (rank) {
1 -> 16
in 2..10 -> 8
else -> 12
}
fun displayContentName(inaccessibleMessage: String): String = when {
!isBlocked -> contentName.ellipsizeByRank()
rank <= 10 -> ""
else -> inaccessibleMessage
}
fun displayCreatorName(): String = if (isBlocked) "" else creatorName
private fun String.ellipsizeByRank(): String {
if (length <= contentNameMaxLength) return this
return "${take(contentNameMaxLength)}..."
}
}

View File

@@ -0,0 +1,159 @@
package kr.co.vividnext.sodalive.v2.widget.contentranking
import android.content.Context
import android.graphics.Outline
import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
class ContentRankingLargeCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var backgroundImage: ImageView? = null
private var contentImage: ImageView? = null
private var deltaGroup: View? = null
private var rankText: TextView? = null
private var deltaAmountText: TextView? = null
private var deltaIcon: ImageView? = null
private var titleText: TextView? = null
private var creatorText: TextView? = null
private var currentItem: ContentRankingItem? = null
private var clickListener: ((ContentRankingItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
backgroundImage = findViewById(R.id.iv_content_ranking_background)
contentImage = findViewById(R.id.iv_content_ranking_image)
deltaGroup = findViewById(R.id.ll_content_ranking_delta)
rankText = findViewById(R.id.tv_content_ranking_rank)
deltaAmountText = findViewById(R.id.tv_content_ranking_delta_amount)
deltaIcon = findViewById(R.id.iv_content_ranking_delta_icon)
titleText = findViewById(R.id.tv_content_ranking_title)
creatorText = findViewById(R.id.tv_content_ranking_creator)
clipToOutline = true
outlineProvider = roundOutlineProvider()
contentImageView().outlineProvider = roundOutlineProvider()
contentImageView().clipToOutline = true
}
fun bind(item: ContentRankingItem) {
currentItem = item
requireNotNull(rankText).apply {
text = item.rank.toString()
applyContentRankingRankGradient()
}
bindDelta(item)
requireNotNull(titleText).apply {
text = item.displayContentName(context.getString(R.string.content_ranking_inaccessible_info))
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
requireNotNull(creatorText).apply {
text = item.displayCreatorName()
visibility = if (text.isNullOrBlank()) View.INVISIBLE else View.VISIBLE
}
applyAccessState(item)
}
fun setCardSize(size: ContentRankingCardSize) {
layoutParams = (layoutParams ?: ViewGroup.LayoutParams(size.widthPx, size.heightPx)).apply {
width = size.widthPx
height = size.heightPx
}
positionViews(size)
}
fun imageView(): ImageView = contentImageView()
fun backgroundImageView(): ImageView = requireNotNull(backgroundImage)
fun setOnContentClick(listener: ((ContentRankingItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyAccessState)
}
private fun bindDelta(item: ContentRankingItem) {
val presentation = ContentRankingDeltaPresentation.from(item.rankChangeType, item.rankChangeAmount)
applyDeltaContainer(presentation)
requireNotNull(deltaIcon).apply {
setImageResource(presentation.iconRes)
layoutParams = (layoutParams as ViewGroup.MarginLayoutParams).apply {
width = presentation.iconWidthDp.dpToPx()
height = presentation.iconHeightDp.dpToPx()
marginStart = presentation.iconMarginStartDp.dpToPx()
}
}
requireNotNull(deltaAmountText).apply {
text = presentation.amountText
visibility = if (presentation.showAmount) View.VISIBLE else View.GONE
}
}
private fun applyDeltaContainer(presentation: ContentRankingDeltaPresentation) {
requireNotNull(deltaGroup).apply {
setBackgroundResource(if (presentation.showPillBackground) R.drawable.bg_creator_ranking_delta else 0)
val horizontalPadding = if (presentation.showPillBackground) 4.dpToPx() else 0
setPadding(horizontalPadding, paddingTop, horizontalPadding, paddingBottom)
}
}
private fun applyAccessState(item: ContentRankingItem) {
applyBlur(item.isInaccessible)
isClickable = item.isTouchable && clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun positionViews(size: ContentRankingCardSize) {
val scale = size.widthPx / FIGMA_WIDTH.toFloat()
requireNotNull(rankText).layoutParams = LayoutParams((91 * scale).roundToInt(), (114 * scale).roundToInt())
contentImageView().layoutParams = LayoutParams((155 * scale).roundToInt(), (154 * scale).roundToInt()).apply {
leftMargin = ((size.widthPx - (155 * scale)) / 2f).roundToInt()
topMargin = (14 * scale).roundToInt()
}
findViewById<View>(R.id.ll_content_ranking_delta).layoutParams = LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = (20 * scale).roundToInt()
topMargin = (109 * scale).roundToInt()
}
findViewById<View>(R.id.ll_content_ranking_label).layoutParams = LayoutParams((165 * scale).roundToInt(), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
leftMargin = ((size.widthPx - (165 * scale)) / 2f).roundToInt()
topMargin = (182 * scale).roundToInt()
}
requireNotNull(rankText).applyContentRankingRankGradient()
}
private fun applyBlur(enabled: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val effect = if (enabled) RenderEffect.createBlurEffect(16f, 16f, Shader.TileMode.CLAMP) else null
contentImageView().setRenderEffect(effect)
backgroundImageView().setRenderEffect(effect)
}
}
private fun contentImageView(): ImageView = requireNotNull(contentImage)
private fun roundOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, 14.dpToPx().toFloat())
}
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt()
private companion object {
const val FIGMA_WIDTH = 374
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.widget.creatorranking
import androidx.annotation.DrawableRes
import 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,

View File

@@ -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,

View File

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

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<ImageView
android:id="@+id/iv_content_ranking_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<View
android:id="@+id/v_content_ranking_dim_gradient"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_dim_gradient" />
<TextView
android:id="@+id/tv_content_ranking_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pattaya_regular"
android:includeFontPadding="false"
android:shadowColor="#7A000000"
android:shadowRadius="4"
android:textColor="@color/white"
android:textSize="54sp"
tools:text="2" />
<LinearLayout
android:id="@+id/ll_content_ranking_delta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_ranking_delta"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<TextView
android:id="@+id/tv_content_ranking_delta_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="4" />
<ImageView
android:id="@+id/iv_content_ranking_delta_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:contentDescription="@null"
tools:src="@drawable/ic_rank_caret_increase" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_content_ranking_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_content_ranking_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="22sp"
tools:text="콘텐츠 이름" />
<TextView
android:id="@+id/tv_content_ranking_creator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/regular"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="12sp"
tools:text="크리에이터 이름" />
</LinearLayout>
</merge>

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingHorizontalCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:id="@+id/ll_content_ranking_rank_group"
android:layout_width="49dp"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_content_ranking_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pattaya_regular"
android:includeFontPadding="false"
android:shadowColor="#7A000000"
android:shadowRadius="4"
android:textColor="@color/white"
android:textSize="40sp"
tools:text="11" />
<LinearLayout
android:id="@+id/ll_content_ranking_delta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_ranking_delta"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<TextView
android:id="@+id/tv_content_ranking_delta_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="4" />
<ImageView
android:id="@+id/iv_content_ranking_delta_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:contentDescription="@null"
tools:src="@drawable/ic_rank_caret_increase" />
</LinearLayout>
</LinearLayout>
<ImageView
android:id="@+id/iv_content_ranking_image"
android:layout_width="80dp"
android:layout_height="80dp"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<LinearLayout
android:id="@+id/ll_content_ranking_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/tv_content_ranking_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="18sp"
tools:text="콘텐츠 이름" />
<TextView
android:id="@+id/tv_content_ranking_creator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/regular"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="14sp"
tools:text="크리에이터 이름" />
</LinearLayout>
<TextView
android:id="@+id/tv_content_ranking_inaccessible"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="18sp"
android:visibility="gone"
tools:text="접근할 수 없는 정보입니다." />
</kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingHorizontalCardView>

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingLargeCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_content_ranking_background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<View
android:id="@+id/v_content_ranking_dim_blur"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A000000" />
<View
android:id="@+id/v_content_ranking_dim_gradient"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_creator_ranking_dim_gradient" />
<TextView
android:id="@+id/tv_content_ranking_rank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/pattaya_regular"
android:includeFontPadding="false"
android:shadowColor="#7A000000"
android:shadowRadius="4"
android:textColor="@color/white"
android:textSize="96sp"
tools:text="1" />
<ImageView
android:id="@+id/iv_content_ranking_image"
android:layout_width="155dp"
android:layout_height="154dp"
android:background="@drawable/bg_creator_ranking_image"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<LinearLayout
android:id="@+id/ll_content_ranking_delta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_creator_ranking_delta"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="4dp">
<TextView
android:id="@+id/tv_content_ranking_delta_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/medium"
android:includeFontPadding="false"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="4" />
<ImageView
android:id="@+id/iv_content_ranking_delta_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:contentDescription="@null"
tools:src="@drawable/ic_rank_caret_increase" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_content_ranking_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_content_ranking_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/bold"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="22sp"
tools:text="콘텐츠 이름" />
<TextView
android:id="@+id/tv_content_ranking_creator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="@font/regular"
android:gravity="center"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
android:textSize="12sp"
tools:text="크리에이터 이름" />
</LinearLayout>
</kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingLargeCardView>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingMediumGridCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/view_content_ranking_grid_card_content" />
</kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingMediumGridCardView>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingSmallGridCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/view_content_ranking_grid_card_content" />
</kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingSmallGridCardView>

View File

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

View File

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

View File

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