feat(widget): 오디오 콘텐츠 태그 배지를 추가한다
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
BIN
app/src/main/res/drawable-mdpi/ic_content_tag_first_star.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_content_tag_first_star.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 324 B |
BIN
app/src/main/res/drawable-mdpi/ic_content_tag_original.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_content_tag_original.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 549 B |
BIN
app/src/main/res/drawable-mdpi/ic_content_tag_point.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_content_tag_point.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 750 B |
4
app/src/main/res/drawable/bg_audio_content_tag_first.xml
Normal file
4
app/src/main/res/drawable/bg_audio_content_tag_first.xml
Normal 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>
|
||||
4
app/src/main/res/drawable/bg_audio_content_tag_free.xml
Normal file
4
app/src/main/res/drawable/bg_audio_content_tag_free.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
239
docs/plan-task/20260522_오디오콘텐츠위젯태그배지.md
Normal file
239
docs/plan-task/20260522_오디오콘텐츠위젯태그배지.md
Normal 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로 대체 검증했다.
|
||||
138
docs/prd/20260522_오디오콘텐츠위젯태그배지_prd.md
Normal file
138
docs/prd/20260522_오디오콘텐츠위젯태그배지_prd.md
Normal 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 문서를 기준으로 별도 진행한다.
|
||||
Reference in New Issue
Block a user