feat(widget): 오디오 콘텐츠 태그 배지를 추가한다

This commit is contained in:
2026-05-27 14:50:59 +09:00
parent a8e0f2377d
commit 799dd7fc92
14 changed files with 693 additions and 6 deletions

View File

@@ -1,11 +1,18 @@
package kr.co.vividnext.sodalive.v2.widget
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import kr.co.vividnext.sodalive.R
import kotlin.math.roundToInt
@@ -15,7 +22,10 @@ class AudioContentCardView @JvmOverloads constructor(
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private var thumbnailContainer: FrameLayout? = null
private var thumbnail: ImageView? = null
private var topTagContainer: LinearLayout? = null
private var bottomTagContainer: LinearLayout? = null
private var labelContainer: LinearLayout? = null
private var titleText: TextView? = null
private var creatorText: TextView? = null
@@ -26,21 +36,31 @@ class AudioContentCardView @JvmOverloads constructor(
override fun onFinishInflate() {
super.onFinishInflate()
thumbnailContainer = findViewById(R.id.fl_audio_content_thumbnail_container)
thumbnail = findViewById(R.id.iv_audio_content_thumbnail)
topTagContainer = findViewById(R.id.ll_audio_content_tag_top)
bottomTagContainer = findViewById(R.id.ll_audio_content_tag_bottom)
labelContainer = findViewById(R.id.ll_audio_content_label)
titleText = findViewById(R.id.tv_audio_content_title)
creatorText = findViewById(R.id.tv_audio_content_creator)
if (isInEditMode && !hasRequiredViews()) return
setThumbnailOutline()
setSize(AudioContentCardSize.Medium)
}
fun setSize(size: AudioContentCardSize) {
updateRootWidth(size.cardWidthDp.dpToPx())
requireNotNull(thumbnail).layoutParams = LayoutParams(
requireNotNull(thumbnailContainer).layoutParams = LayoutParams(
size.thumbnailSizeDp.dpToPx(),
size.thumbnailSizeDp.dpToPx()
)
requireNotNull(thumbnail).layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
requireNotNull(labelContainer).layoutParams = LayoutParams(
size.labelWidthDp.dpToPx(),
ViewGroup.LayoutParams.WRAP_CONTENT
@@ -70,6 +90,115 @@ class AudioContentCardView @JvmOverloads constructor(
fun thumbnailView(): ImageView = requireNotNull(thumbnail)
fun setTags(tags: Set<AudioContentTag>) {
renderTags(requireNotNull(topTagContainer), orderedAudioContentTopTags(tags))
renderTags(requireNotNull(bottomTagContainer), orderedAudioContentBottomTags(tags))
}
private fun renderTags(
container: LinearLayout,
tags: List<AudioContentTag>
) {
container.removeAllViews()
container.visibility = if (tags.isEmpty()) GONE else VISIBLE
tags.forEach { tag ->
container.addView(createTagView(tag))
}
}
private fun createTagView(tag: AudioContentTag): View = when (tag) {
AudioContentTag.Original -> createIconTag(R.drawable.ic_content_tag_original)
AudioContentTag.Point -> createIconTag(R.drawable.ic_content_tag_point)
AudioContentTag.First -> createFirstTag()
AudioContentTag.Free -> createFreeTag()
}
private fun createIconTag(drawableResId: Int): ImageView {
return ImageView(context).apply {
setImageResource(drawableResId)
contentDescription = null
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
layoutParams = LinearLayout.LayoutParams(TAG_HEIGHT_DP.dpToPx(), TAG_HEIGHT_DP.dpToPx())
}
}
private fun createFirstTag(): LinearLayout {
return LinearLayout(context).apply {
orientation = HORIZONTAL
background = ContextCompat.getDrawable(context, R.drawable.bg_audio_content_tag_first)
layoutParams = LinearLayout.LayoutParams(FIRST_TAG_WIDTH_DP.dpToPx(), TAG_HEIGHT_DP.dpToPx())
addView(createFirstStarView())
addView(createFirstTextView())
}
}
private fun createFirstStarView(): ImageView {
return ImageView(context).apply {
setImageResource(R.drawable.ic_content_tag_first_star)
contentDescription = null
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
layoutParams = LinearLayout.LayoutParams(FIRST_STAR_SIZE_DP.dpToPx(), FIRST_STAR_SIZE_DP.dpToPx()).apply {
marginStart = 2.dpToPx()
topMargin = 4.dpToPx()
}
}
}
private fun createFirstTextView(): TextView {
return TextView(context).apply {
text = FIRST_TEXT
typeface = ResourcesCompat.getFont(context, R.font.phosphate_solid)
setTextColor(ContextCompat.getColor(context, R.color.white))
textSize = 16f
isSingleLine = true
includeFontPadding = false
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
marginStart = 1.dpToPx()
topMargin = 2.dpToPx()
}
}
}
private fun createFreeTag(): TextView {
return TextView(context).apply {
text = context.getString(R.string.audio_content_tag_free)
background = ContextCompat.getDrawable(context, R.drawable.bg_audio_content_tag_free)
setTextColor(ContextCompat.getColor(context, R.color.white))
textSize = 14f
gravity = Gravity.CENTER
isSingleLine = true
includeFontPadding = false
minWidth = FREE_TAG_MIN_WIDTH_DP.dpToPx()
setPadding(FREE_TAG_HORIZONTAL_PADDING_DP.dpToPx(), 0, FREE_TAG_HORIZONTAL_PADDING_DP.dpToPx(), 0)
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, TAG_HEIGHT_DP.dpToPx())
}
}
private fun setThumbnailOutline() {
requireNotNull(thumbnailContainer).apply {
clipToOutline = true
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, resources.getDimension(R.dimen.radius_14))
}
}
}
}
private fun hasRequiredViews(): Boolean {
return thumbnailContainer != null &&
thumbnail != null &&
topTagContainer != null &&
bottomTagContainer != null &&
labelContainer != null &&
titleText != null &&
creatorText != null
}
private fun updateRootWidth(width: Int) {
val currentLayoutParams = layoutParams
layoutParams = if (currentLayoutParams == null) {
@@ -86,5 +215,11 @@ class AudioContentCardView @JvmOverloads constructor(
private companion object {
const val TITLE_CREATOR_GAP_DP = 2
const val TAG_HEIGHT_DP = 24
const val FIRST_TAG_WIDTH_DP = 62
const val FIRST_STAR_SIZE_DP = 17
const val FREE_TAG_MIN_WIDTH_DP = 34
const val FREE_TAG_HORIZONTAL_PADDING_DP = 6
const val FIRST_TEXT = "FIRST"
}
}

View File

@@ -0,0 +1,29 @@
package kr.co.vividnext.sodalive.v2.widget
enum class AudioContentTag {
Original,
First,
Point,
Free
}
fun Collection<AudioContentTag>.audioContentTopTags(): List<AudioContentTag> = orderedBy(
AudioContentTag.Original,
AudioContentTag.First
)
fun Collection<AudioContentTag>.audioContentBottomTags(): List<AudioContentTag> = orderedBy(
AudioContentTag.Point,
AudioContentTag.Free
)
fun orderedAudioContentTopTags(tags: Collection<AudioContentTag>): List<AudioContentTag> = tags.audioContentTopTags()
fun orderedAudioContentBottomTags(tags: Collection<AudioContentTag>): List<AudioContentTag> = tags.audioContentBottomTags()
private fun Collection<AudioContentTag>.orderedBy(
vararg orderedTags: AudioContentTag
): List<AudioContentTag> {
val includedTags = toSet()
return orderedTags.filter { it in includedTags }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

View File

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

View File

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

View File

@@ -5,13 +5,92 @@
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:id="@+id/iv_audio_content_thumbnail"
<FrameLayout
android:id="@+id/fl_audio_content_thumbnail_container"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/bg_audio_content_card_thumbnail"
android:contentDescription="@null"
android:scaleType="centerCrop" />
android:background="@drawable/bg_audio_content_card_thumbnail">
<ImageView
android:id="@+id/iv_audio_content_thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<LinearLayout
android:id="@+id/ll_audio_content_tag_top"
android:layout_width="wrap_content"
android:layout_height="@dimen/spacing_24"
android:layout_gravity="top|start"
android:orientation="horizontal"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="@dimen/spacing_24"
android:layout_height="@dimen/spacing_24"
android:contentDescription="@null"
android:src="@drawable/ic_content_tag_original" />
<LinearLayout
android:layout_width="62dp"
android:layout_height="@dimen/spacing_24"
android:background="@drawable/bg_audio_content_tag_first"
android:orientation="horizontal">
<ImageView
android:layout_width="17dp"
android:layout_height="17dp"
android:layout_marginStart="2dp"
android:layout_marginTop="4dp"
android:contentDescription="@null"
android:src="@drawable/ic_content_tag_first_star" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="1dp"
android:layout_marginTop="2dp"
android:fontFamily="@font/phosphate_solid"
android:includeFontPadding="false"
android:singleLine="true"
android:text="FIRST"
android:textColor="@color/white"
android:textSize="16sp"
tools:ignore="HardcodedText" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/ll_audio_content_tag_bottom"
android:layout_width="wrap_content"
android:layout_height="@dimen/spacing_24"
android:layout_gravity="bottom|start"
android:orientation="horizontal"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="@dimen/spacing_24"
android:layout_height="@dimen/spacing_24"
android:contentDescription="@null"
android:src="@drawable/ic_content_tag_point" />
<TextView
android:layout_width="wrap_content"
android:layout_height="@dimen/spacing_24"
android:background="@drawable/bg_audio_content_tag_free"
android:gravity="center"
android:includeFontPadding="false"
android:minWidth="34dp"
android:paddingHorizontal="6dp"
android:singleLine="true"
android:text="@string/audio_content_tag_free"
android:textColor="@color/white"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>
<LinearLayout
android:id="@+id/ll_audio_content_label"

View File

@@ -1161,6 +1161,7 @@ The upload will continue even if you leave this page.</string>
<string name="audio_content_label_all">All</string>
<string name="audio_content_total_unit">items</string>
<string name="audio_content_price_free">Free</string>
<string name="audio_content_tag_free">Free</string>
<string name="audio_content_badge_scheduled">Coming soon</string>
<string name="audio_content_badge_point">Points</string>
<string name="audio_content_badge_owned">Owned</string>

View File

@@ -1159,6 +1159,7 @@
<string name="audio_content_label_all"></string>
<string name="audio_content_total_unit"></string>
<string name="audio_content_price_free">無料</string>
<string name="audio_content_tag_free">無料</string>
<string name="audio_content_badge_scheduled">公開予定</string>
<string name="audio_content_badge_point">ポイント</string>
<string name="audio_content_badge_owned">所持中</string>

View File

@@ -1158,6 +1158,7 @@
<string name="audio_content_label_all">전체</string>
<string name="audio_content_total_unit"></string>
<string name="audio_content_price_free">무료</string>
<string name="audio_content_tag_free">무료</string>
<string name="audio_content_badge_scheduled">오픈예정</string>
<string name="audio_content_badge_point">포인트</string>
<string name="audio_content_badge_owned">소장중</string>

View File

@@ -0,0 +1,56 @@
package kr.co.vividnext.sodalive.v2.widget
import org.junit.Assert.assertEquals
import org.junit.Test
class AudioContentTagTest {
@Test
fun `top tags keep original first order`() {
val tags = listOf(AudioContentTag.First, AudioContentTag.Original)
assertEquals(
listOf(AudioContentTag.Original, AudioContentTag.First),
tags.audioContentTopTags()
)
}
@Test
fun `bottom tags keep point free order`() {
val tags = listOf(AudioContentTag.Free, AudioContentTag.Point)
assertEquals(
listOf(AudioContentTag.Point, AudioContentTag.Free),
tags.audioContentBottomTags()
)
}
@Test
fun `duplicate tags are displayed once`() {
val tags = listOf(
AudioContentTag.First,
AudioContentTag.Original,
AudioContentTag.First,
AudioContentTag.Point,
AudioContentTag.Point,
AudioContentTag.Free
)
assertEquals(
listOf(AudioContentTag.Original, AudioContentTag.First),
tags.audioContentTopTags()
)
assertEquals(
listOf(AudioContentTag.Point, AudioContentTag.Free),
tags.audioContentBottomTags()
)
}
@Test
fun `empty tags return empty rows`() {
val tags = emptyList<AudioContentTag>()
assertEquals(emptyList<AudioContentTag>(), tags.audioContentTopTags())
assertEquals(emptyList<AudioContentTag>(), tags.audioContentBottomTags())
}
}