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,14 +5,93 @@
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: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"
android:layout_width="0dp"

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())
}
}

View File

@@ -0,0 +1,239 @@
# 오디오 콘텐츠 위젯 태그 배지 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:3840`, `20:3843`, `20:3815`, `20:3814` 기준으로 v2 패키지 아래 신규 위젯인 `AudioContentCardView` 썸네일 영역에 Original, First, Point, Free 태그 배지를 추가한다.
**Architecture:** v2 신규 위젯인 `AudioContentCardView``view_audio_content_card.xml`만 수정해 썸네일 `ImageView`를 overlay 가능한 container 안으로 이동하고 상단/하단 tag row를 추가한다. 태그 타입과 정렬 순서는 순수 Kotlin contract로 분리해 단위 테스트하고, 실제 이미지 로딩은 기존 `thumbnailView()` API로 계속 호출부에 위임한다. 레거시 화면, 레거시 adapter, 레거시 XML에는 포함하지 않는다.
**Tech Stack:** Android XML Views, Kotlin custom View, Android resources, JUnit4 local unit test.
---
## 작업 목표
- 대상은 v2 신규 위젯 파일인 `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt``app/src/main/res/layout/view_audio_content_card.xml`로 한정한다.
- 레거시 화면과 기존 화면 적용 작업은 제외한다.
- Original, First는 썸네일 왼쪽 상단에 표시한다.
- Original과 First가 함께 있으면 Original, First 순서로 표시한다.
- Point, Free는 썸네일 왼쪽 하단에 표시한다.
- Free 태그는 string resource 기반 다국어 텍스트로 생성한다.
- 기존 `setContent`, `thumbnailView`, `setSize` API는 유지한다.
- 구현 중 체크박스와 검증 기록은 이 문서에 누적한다.
## 파일 구조
- Read: `docs/prd/20260522_오디오콘텐츠위젯태그배지_prd.md`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt`
- tag view 참조, `setTags(...)`, 상/하단 정렬 적용, size 적용 시 thumbnail container 크기 갱신을 처리한다.
- Modify: `app/src/main/res/layout/view_audio_content_card.xml`
- thumbnail overlay container와 top/bottom tag row를 추가한다.
- Do not modify: 레거시 화면 XML, 레거시 adapter, 기존 API/DTO, 기존 화면 바인딩 코드
- 신규 위젯 자체 기능만 추가한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTag.kt`
- `Original`, `First`, `Point`, `Free` 태그 타입과 위치/정렬 계약을 정의한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTagTest.kt`
- 태그 중복 제거, 상단/하단 분류, 고정 정렬 순서를 검증한다.
- Modify: `app/src/main/res/values/strings.xml`
- `audio_content_tag_free` 문자열을 추가한다.
- Modify: `app/src/main/res/values-en/strings.xml`
- `audio_content_tag_free` 영어 문자열을 추가한다.
- Modify: `app/src/main/res/values-ja/strings.xml`
- `audio_content_tag_free` 일본어 문자열을 추가한다.
- Add if missing: `app/src/main/res/drawable/ic_content_tag_original.png`
- Add if missing: `app/src/main/res/drawable/ic_content_tag_point.png`
- Add if missing: `app/src/main/res/drawable/ic_content_tag_first_star.png`
- Create if missing: `app/src/main/res/drawable/bg_audio_content_tag_first.xml`
- `#FF34B8` solid background for the `62dp x 24dp` First badge.
- Create if missing: `app/src/main/res/drawable/bg_audio_content_tag_free.xml`
- `#052742` solid background for the Free badge. The view uses height `24dp`, min width `34dp`, and `wrap_content` width.
- Modify: `docs/plan-task/20260522_오디오콘텐츠위젯태그배지.md`
- 구현 중 체크박스와 검증 기록을 누적한다.
## 구현 계획
### Task 1: 기존 위젯과 Figma 기준 확인
**Files:**
- Read: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt`
- Read: `app/src/main/res/layout/view_audio_content_card.xml`
- Read: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt`
- Read: `docs/prd/20260519_오디오콘텐츠카드컴포넌트_prd.md`
- Read: `docs/prd/20260522_오디오콘텐츠위젯태그배지_prd.md`
- [x] **Step 1: 대상 위젯 확인**
Run: `rg -n "class AudioContentCardView|iv_audio_content_thumbnail|ll_audio_content_label|AudioContentCardSize|thumbnailView" app/src/main/java/kr/co/vividnext/sodalive/v2/widget app/src/main/res/layout/view_audio_content_card.xml`
Expected: 변경 대상이 v2 신규 위젯인 `AudioContentCardView.kt``view_audio_content_card.xml`이며, 기존 size/content/thumbnail API를 유지해야 함을 확인한다. 레거시 화면 파일은 변경 대상에서 제외한다.
- [ ] **Step 2: Figma tag 기준 확인**
Run tools:
- `Figma_get_design_context(20:3840)`
- `Figma_get_screenshot(20:3840)`
- `Figma_get_design_context(20:3843)`
- `Figma_get_screenshot(20:3843)`
- `Figma_get_design_context(20:3815)`
- `Figma_get_screenshot(20:3815)`
- `Figma_get_design_context(20:3814)`
- `Figma_get_screenshot(20:3814)`
Expected: First, Free 단일 tag와 Free/Point + Original/First 조합의 위치, 크기, 색상, 순서를 확인한다.
- [x] **Step 3: 기존 리소스 확인**
Run: `rg -n "ic_content_tag_original|ic_content_tag_point|ic_content_tag_first_star|audio_content_tag_free|#ff34b8|#052742" app/src/main/res`
Expected: 재사용 가능한 리소스가 있으면 재사용하고, 없으면 최소 신규 리소스를 추가한다.
### Task 2: AudioContentTag contract TDD
**Files:**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTagTest.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTag.kt`
- [x] **Step 1: RED - 태그 정렬 테스트 추가**
Test cases:
- 전달 순서가 `First`, `Original`이어도 top row는 `Original`, `First`가 된다.
- 전달 순서가 `Free`, `Point`이어도 bottom row는 `Point`, `Free`가 된다.
- 중복 태그는 한 번만 표시된다.
- 빈 set은 top/bottom 모두 비어 있다.
- [x] **Step 2: RED 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentTagTest"`
Expected: `Unresolved reference 'AudioContentTag'` 또는 `Unresolved reference 'audioContentTopTags'` / `Unresolved reference 'audioContentBottomTags'`로 실패한다.
- [x] **Step 3: GREEN - 최소 contract 추가**
Implementation notes:
- `enum class AudioContentTag { Original, First, Point, Free }`를 추가한다.
- `fun Collection<AudioContentTag>.audioContentTopTags(): List<AudioContentTag>``[Original, First]` 순서를 적용한다.
- `fun Collection<AudioContentTag>.audioContentBottomTags(): List<AudioContentTag>``[Point, Free]` 순서를 적용한다.
- [x] **Step 4: GREEN 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentTagTest"`
Expected: `BUILD SUCCESSFUL`
### Task 3: XML overlay 구조와 리소스 추가
**Files:**
- Modify: `app/src/main/res/layout/view_audio_content_card.xml`
- 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`
- Add if missing: drawable resources for tags/backgrounds
- [x] **Step 1: thumbnail overlay container 적용**
Implementation notes:
- 기존 `iv_audio_content_thumbnail``FrameLayout` overlay container 내부로 이동한다.
- container id는 예: `fl_audio_content_thumbnail_container`로 둔다.
- `AudioContentCardView.setSize`는 thumbnail `ImageView`와 container 모두 `thumbnailSizeDp` 크기로 맞춘다.
- 기존 `thumbnailView()`는 동일한 `ImageView`를 반환한다.
- [x] **Step 2: top/bottom tag row 추가**
Implementation notes:
- top row id 예: `ll_audio_content_tag_top`
- bottom row id 예: `ll_audio_content_tag_bottom`
- top row: parent start/top 정렬, horizontal orientation
- bottom row: parent start/bottom 정렬, horizontal orientation
- row는 태그가 없을 때 `gone` 처리한다.
- [x] **Step 3: Free 다국어 문자열 추가**
Suggested strings:
- `values/strings.xml`: `무료`
- `values-en/strings.xml`: `Free`
- `values-ja/strings.xml`: `無料`
- [x] **Step 4: resource merge 확인**
Run: `./gradlew :app:assembleDebug`
Expected: Android resource merge와 debug assemble이 성공한다.
### Task 4: AudioContentCardView tag binding 구현
**Files:**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt`
- [x] **Step 1: view 참조 추가**
Implementation notes:
- thumbnail container, top tag row, bottom tag row를 `onFinishInflate()`에서 찾는다.
- `setSize()`에서 thumbnail container와 thumbnail image 크기를 함께 갱신한다.
- [x] **Step 2: public tag API 추가**
Implementation notes:
- `fun setTags(tags: Set<AudioContentTag>)` API를 추가한다.
- top row는 `tags.audioContentTopTags()` 결과를 렌더링한다.
- bottom row는 `tags.audioContentBottomTags()` 결과를 렌더링한다.
- 빈 row는 `View.GONE`, 표시 row는 `View.VISIBLE`로 처리한다.
- 기존 public API는 제거하거나 시그니처 변경하지 않는다.
- [x] **Step 3: tag view 생성 로직 추가**
Implementation notes:
- Original: `ImageView` + `ic_content_tag_original`, `24dp x 24dp`
- Point: `ImageView` + `ic_content_tag_point`, `24dp x 24dp`
- First: `LinearLayout` + `bg_audio_content_tag_first` + `ic_content_tag_first_star` + `FIRST` 텍스트, `62dp x 24dp`
- star icon: `17dp x 17dp`, `marginStart=2dp`, `top=4dp`, `contentDescription=null`
- text: `Phosphate Solid`, `16sp`, white, `singleLine=true`, `includeFontPadding=false`, `marginStart=1dp`, `top=2dp`
- Free: `TextView` + `bg_audio_content_tag_free` + `@string/audio_content_tag_free`, height `24dp`, min width `34dp`, width `wrap_content`
- 장식 아이콘의 `contentDescription``null`로 둔다.
### Task 5: 검증
**Files:**
- All changed implementation/resources/tests
- Modify: `docs/plan-task/20260522_오디오콘텐츠위젯태그배지.md`
- [x] **Step 1: 단위 테스트 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentTagTest"`
Expected: `BUILD SUCCESSFUL`
- [x] **Step 2: 리소스/빌드 검증 실행**
Run: `./gradlew :app:assembleDebug`
Expected: `BUILD SUCCESSFUL`
- [x] **Step 3: LSP diagnostics 확인**
Run tools:
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTag.kt)`
- `lsp_diagnostics(app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTagTest.kt)`
Expected: 변경 파일에 신규 error가 없다.
- [x] **Step 4: 검증 기록 누적**
이 문서 하단에 무엇/왜/어떻게, 실행 명령, 결과, 남은 이슈를 한국어로 누적한다.
- [x] **Step 5: 레거시 변경 없음 확인**
Run: `git diff --name-only | rg -v "^(docs/prd/20260522_오디오콘텐츠위젯태그배지_prd.md|docs/plan-task/20260522_오디오콘텐츠위젯태그배지.md|app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt|app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTag.kt|app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTagTest.kt|app/src/main/res/layout/view_audio_content_card.xml|app/src/main/res/values/strings.xml|app/src/main/res/values-en/strings.xml|app/src/main/res/values-ja/strings.xml|app/src/main/res/drawable/)"`
Expected: 출력이 없어야 한다. 출력이 있으면 레거시 화면 또는 범위 밖 파일을 변경한 것이므로 되돌리거나 계획을 갱신하기 전에 사용자 확인을 받는다.
## 검증 기록
### 2026-05-22 문서 생성
- 무엇/왜/어떻게: 사용자 요청에 따라 오디오 콘텐츠 위젯 태그 배지 추가 작업의 PRD와 구현 계획/TASK 문서를 작성했다. 대상 위젯은 v2 패키지 아래 신규 위젯인 `AudioContentCardView.kt``view_audio_content_card.xml`로 한정했고, Figma `20:3840`, `20:3843`, `20:3815`, `20:3814` 기준 태그 종류/위치/정렬/다국어 요구사항을 반영했다. 레거시 화면에는 포함하지 않는다는 제약도 문서에 반영했다.
- 실행 명령: 문서 작성만 수행했으므로 Gradle 빌드는 실행하지 않았다.
- 결과: 구현 전 기준 문서가 준비되었다.
### 2026-05-27 구현 및 검증
- 무엇/왜/어떻게: `AudioContentCardView` 썸네일을 `FrameLayout` overlay container로 감싸고 top/bottom tag row를 추가했다. `AudioContentTag` enum과 `audioContentTopTags()` / `audioContentBottomTags()` 순수 Kotlin contract를 추가해 Original/First, Point/Free 정렬과 중복 제거를 테스트했다. `setTags(tags: Set<AudioContentTag>)` API를 추가해 Original, First, Point, Free 태그를 썸네일 내부에 렌더링하도록 구현했으며, 기존 `setContent`, `setSize`, `thumbnailView` API는 유지했다. Free 태그 문자열과 First/Free 배경 drawable도 추가했다.
- 실행 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentTagTest"`를 RED/GREEN으로 실행했고, `./gradlew :app:assembleDebug`를 실행했다. `git diff --name-only`와 계획 문서의 범위 확인 명령으로 레거시 변경 여부를 확인했다.
- 결과: RED 단계는 `Unresolved reference 'AudioContentTag'``Unresolved reference 'audioContentTopTags'` / `audioContentBottomTags` 컴파일 실패로 확인했다. GREEN 단계의 targeted unit test와 `assembleDebug`는 모두 `BUILD SUCCESSFUL`로 통과했다. debug APK 산출물 `app/build/outputs/apk/debug/app-debug.apk`도 생성되었다.
- 남은 이슈: Figma MCP `Figma_get_design_context` 호출은 timeout으로 완료하지 못해, 태그 크기/위치/색상은 PRD와 이 계획 문서에 이미 정리된 Figma 기준 및 기존 `SeriesContentCardView` 패턴을 근거로 구현했다. Kotlin LSP는 `kotlin-lsp` 미설치로 diagnostics를 실행할 수 없었고, Gradle compile/test/build로 대체 검증했다.

View File

@@ -0,0 +1,138 @@
# PRD: 오디오 콘텐츠 위젯 태그 배지
## 1. Overview
Figma `20:3840`, `20:3843`, `20:3815`, `20:3814` 디자인을 기준으로 v2 패키지 아래에 생성된 신규 오디오 콘텐츠 위젯(`AudioContentCardView`, `view_audio_content_card.xml`)의 썸네일 영역에 콘텐츠 태그 배지를 표시한다.
---
## 2. Problem
- 기존 `AudioContentCardView`는 썸네일, 제목, 크리에이터명만 제공해 콘텐츠의 구매/원작/선공개 속성을 카드에서 즉시 구분할 수 없다.
- 기존 오디오 콘텐츠 카드 PRD에서는 태그 배지를 명시적으로 제외했으므로, 이번 추가 요구사항을 별도 문서로 확정해야 한다.
- 태그별 위치와 표시 순서가 정해져 있어 호출부마다 임의 구현하면 Original/First, Point/Free 정렬이 달라질 수 있다.
- Free 태그는 이미지 리소스가 아니라 다국어 문자열로 생성해야 하므로 string resource 기반 계약이 필요하다.
---
## 3. Goals
- `AudioContentCardView` 썸네일 위에 Original, First, Point, Free 태그 배지를 표시할 수 있게 한다.
- 대상 구현 파일은 v2 패키지 아래 신규 위젯으로 생성된 다음 파일로 한정한다.
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt`
- `app/src/main/res/layout/view_audio_content_card.xml`
- 왼쪽 상단에는 Original, First를 표시하고, 둘 다 있으면 Original 다음 First 순서를 유지한다.
- 왼쪽 하단에는 Point, Free를 표시한다.
- Original, Point는 제공된 drawable 리소스를 사용한다.
- First는 `ic_content_tag_first_star` 리소스를 사용해 Figma `20:3840` 형태의 배지를 생성한다.
- Free는 Figma `20:3843` 기준으로 다국어 string resource 텍스트를 사용해 생성한다.
- Figma 예시 조합을 지원한다.
- Free + Original + First: `20:3815`
- Point + Original + First: `20:3814`
---
## 4. Non-Goals
- 피드 위젯(`kr.co.vividnext.sodalive.v2.widget.feed.*`)에는 이번 변경을 적용하지 않는다.
- 레거시 화면, 레거시 adapter, 레거시 XML에는 이번 변경을 포함하지 않는다.
- 기존 화면, adapter, API, DTO에 태그 값을 일괄 연결하지 않는다.
- 썸네일 이미지 로딩 정책이나 placeholder 정책을 변경하지 않는다.
- 오디오 콘텐츠 카드의 기존 size contract(`large`, `medium`, `small`)를 변경하지 않는다.
- Compose 컴포넌트 또는 신규 Activity/Fragment/ViewModel을 만들지 않는다.
- Figma에 없는 추가 badge, animation, dim overlay, pressed effect는 추가하지 않는다.
---
## 5. Target Users
- v2 오디오 콘텐츠 카드에서 콘텐츠 상태를 빠르게 구분하려는 앱 사용자.
- `AudioContentCardView`를 XML Views 기반 목록, 캐러셀, 그리드에서 재사용하는 Android 개발자.
---
## 6. User Stories
- 사용자는 콘텐츠 썸네일에서 Original, First, Point, Free 속성을 즉시 확인하고 싶다.
- 개발자는 오디오 콘텐츠 카드에 표시할 태그 목록만 전달하고, 카드 내부에서 Figma 기준 위치와 순서가 자동으로 적용되기를 원한다.
- 개발자는 Free 태그 문구가 한국어/영어/일본어 등 기존 다국어 정책을 따르기를 원한다.
---
## 7. Core Features
### Audio Content Card Tag Badges
v2 신규 위젯인 `AudioContentCardView`의 썸네일 영역을 overlay 가능한 container로 확장하고, 태그 배지를 카드 썸네일 내부의 왼쪽 상단/왼쪽 하단에 배치한다. 레거시 화면에 include하거나 레거시 카드 구현을 수정하지 않는다.
#### Figma References
- First tag: 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-3840&m=dev
- Free tag: 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-3843&m=dev
- Free + Original + First example: 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-3815&m=dev
- Point + Original + First example: 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-3814&m=dev
#### Tag Types
| Tag | 위치 | 구현 기준 | 리소스/텍스트 |
| --- | --- | --- | --- |
| `Original` | 왼쪽 상단 | Figma 예시의 24dp 정사각 original audio tag | `ic_content_tag_original` |
| `First` | 왼쪽 상단 | `#FF34B8` 배경 + 별 아이콘 + `FIRST` 텍스트 | `ic_content_tag_first_star` |
| `Point` | 왼쪽 하단 | Figma 예시의 24dp 정사각 point tag | `ic_content_tag_point` |
| `Free` | 왼쪽 하단 | `#052742` 배경 + 다국어 텍스트 | `@string/audio_content_tag_free` |
#### Layout Requirements
- 태그 높이는 Figma 기준 `24dp`를 사용한다.
- Original과 Point는 `24dp x 24dp` 아이콘 태그로 표시한다.
- First는 `62dp x 24dp` 배지로 표시한다.
- First 텍스트는 Figma `20:3840` 기준 `Phosphate Solid`, `16sp`, white, 단일 행, 대문자 `FIRST`로 표시한다.
- First 텍스트 위치는 배지 좌측 `20dp`, 상단 `2dp`를 기준으로 하며, 별 아이콘은 좌측 `2dp`, 상단 `4dp`, `17dp x 17dp`로 배치한다.
- Free는 높이 `24dp`, 최소 폭 `34dp`, 가로 `wrap_content` 배지로 표시한다. 한국어 `무료`는 Figma 기준 `34dp` 폭으로 보이고, 다른 locale 문구가 더 길면 텍스트가 잘리지 않도록 좌우 padding을 유지한 채 폭을 확장한다.
- 왼쪽 상단 tag row는 썸네일의 `start=0`, `top=0`에 배치한다.
- 왼쪽 하단 tag row는 썸네일의 `start=0`, `bottom=0`에 배치한다.
- 상단 row에서 Original과 First가 함께 있으면 항상 Original, First 순서로 표시한다.
- 하단 row에서 Point와 Free가 함께 있으면 Point, Free 순서로 표시한다.
- 태그 row는 썸네일 radius와 함께 잘려야 하며 카드 바깥으로 넘치지 않아야 한다.
#### Data Contract Requirements
- 태그 표시는 순수 Kotlin contract로 관리한다.
- `AudioContentTag` enum에 `Original`, `First`, `Point`, `Free`를 정의한다.
- `AudioContentCardView``setTags(tags: Set<AudioContentTag>)` API를 제공한다.
- 태그가 비어 있으면 모든 태그 row를 숨긴다.
- `setContent(title, creatorName)` 기존 API는 유지한다.
- `thumbnailView()` 기존 API는 유지해 호출부 이미지 로딩 방식을 변경하지 않는다.
#### Edge Cases
- 동일 태그가 중복 전달되면 한 번만 표시한다.
- 전달 순서와 무관하게 위치별 고정 순서를 적용한다.
- Free와 Point가 모두 전달되면 둘 다 왼쪽 하단에 표시한다.
- Original과 First가 모두 전달되면 둘 다 왼쪽 상단에 표시한다.
- 태그가 없는 기존 사용처는 현재와 같은 UI를 유지한다.
---
## 8. UX / UI Expectations
- 태그 배지는 썸네일 위에 직접 겹쳐 보이며, 제목/크리에이터 label 영역에는 영향을 주지 않는다.
- large, medium, small 카드 모두 동일한 태그 크기와 위치를 사용한다.
- Figma 예시처럼 Original/First는 상단 왼쪽에서 붙어 있고, Point/Free는 하단 왼쪽에서 붙어 있다.
- Free 태그는 string resource 기반으로 현재 locale에 맞는 문구를 표시한다.
- 장식 아이콘은 접근성 노출이 필요하지 않으면 `contentDescription=@null`로 둔다.
---
## 9. Technical Constraints
- 현재 프로젝트는 Android XML Views + Kotlin custom View 패턴을 사용한다.
- 신규 Kotlin 코드는 `kr.co.vividnext.sodalive.v2.widget` 패키지 하위에 작성한다.
- 기존 `AudioContentCardView`의 public API 호환성을 깨지 않는다.
- 기존 카드 크기 계산은 v2 신규 위젯의 `AudioContentCardSize`를 유지한다.
- 색상과 typography는 기존 token을 우선 사용하되, Figma 고유 색상(`#FF34B8`, `#052742`)은 전용 drawable 리소스로 최소 추가한다.
- 다국어 문자열은 `values/strings.xml`, `values-en/strings.xml`, `values-ja/strings.xml`에 추가한다.
- 아이콘 리소스는 `ic_content_tag_original`, `ic_content_tag_point`, `ic_content_tag_first_star` 이름으로 제공한다.
---
## 10. Metrics
- `AudioContentCardView`에서 4가지 태그 타입을 모두 표시할 수 있다.
- Original + First 조합은 왼쪽 상단에 Original, First 순서로 표시된다.
- Point + Free 조합은 왼쪽 하단에 Point, Free 순서로 표시된다.
- Free 태그는 string resource를 통해 다국어 처리된다.
- 태그가 없는 기존 오디오 콘텐츠 카드 UI는 변경되지 않는다.
- 관련 unit test와 Android resource merge/build가 성공한다.
---
## 11. Open Questions
- 사용자 확인으로 대상 위젯은 v2 패키지 아래 신규 위젯인 `AudioContentCardView``view_audio_content_card.xml`로 확정한다.
- 레거시 화면에는 포함하지 않고, 신규 위젯 자체의 표시 기능으로만 제공한다.
- 이번 작업은 문서 생성 범위이며, 구현은 계획/TASK 문서를 기준으로 별도 진행한다.