From 799dd7fc92af7170cabac31295e3c478fbc9062c Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 27 May 2026 14:50:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(widget):=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=ED=83=9C=EA=B7=B8=20=EB=B0=B0?= =?UTF-8?q?=EC=A7=80=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/widget/AudioContentCardView.kt | 137 +++++++++- .../sodalive/v2/widget/AudioContentTag.kt | 29 +++ .../ic_content_tag_first_star.png | Bin 0 -> 324 bytes .../drawable-mdpi/ic_content_tag_original.png | Bin 0 -> 549 bytes .../drawable-mdpi/ic_content_tag_point.png | Bin 0 -> 750 bytes .../drawable/bg_audio_content_tag_first.xml | 4 + .../drawable/bg_audio_content_tag_free.xml | 4 + .../res/layout/view_audio_content_card.xml | 89 ++++++- app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../sodalive/v2/widget/AudioContentTagTest.kt | 56 ++++ .../20260522_오디오콘텐츠위젯태그배지.md | 239 ++++++++++++++++++ .../20260522_오디오콘텐츠위젯태그배지_prd.md | 138 ++++++++++ 14 files changed, 693 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTag.kt create mode 100644 app/src/main/res/drawable-mdpi/ic_content_tag_first_star.png create mode 100644 app/src/main/res/drawable-mdpi/ic_content_tag_original.png create mode 100644 app/src/main/res/drawable-mdpi/ic_content_tag_point.png create mode 100644 app/src/main/res/drawable/bg_audio_content_tag_first.xml create mode 100644 app/src/main/res/drawable/bg_audio_content_tag_free.xml create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTagTest.kt create mode 100644 docs/plan-task/20260522_오디오콘텐츠위젯태그배지.md create mode 100644 docs/prd/20260522_오디오콘텐츠위젯태그배지_prd.md diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt index ca8df8ca..b647639e 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt @@ -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) { + renderTags(requireNotNull(topTagContainer), orderedAudioContentTopTags(tags)) + renderTags(requireNotNull(bottomTagContainer), orderedAudioContentBottomTags(tags)) + } + + private fun renderTags( + container: LinearLayout, + tags: List + ) { + 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" } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTag.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTag.kt new file mode 100644 index 00000000..2f31e04f --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTag.kt @@ -0,0 +1,29 @@ +package kr.co.vividnext.sodalive.v2.widget + +enum class AudioContentTag { + Original, + First, + Point, + Free +} + +fun Collection.audioContentTopTags(): List = orderedBy( + AudioContentTag.Original, + AudioContentTag.First +) + +fun Collection.audioContentBottomTags(): List = orderedBy( + AudioContentTag.Point, + AudioContentTag.Free +) + +fun orderedAudioContentTopTags(tags: Collection): List = tags.audioContentTopTags() + +fun orderedAudioContentBottomTags(tags: Collection): List = tags.audioContentBottomTags() + +private fun Collection.orderedBy( + vararg orderedTags: AudioContentTag +): List { + val includedTags = toSet() + return orderedTags.filter { it in includedTags } +} diff --git a/app/src/main/res/drawable-mdpi/ic_content_tag_first_star.png b/app/src/main/res/drawable-mdpi/ic_content_tag_first_star.png new file mode 100644 index 0000000000000000000000000000000000000000..e81e30f7e4637872e822b29bbee5c7df3b795c53 GIT binary patch literal 324 zcmeAS@N?(olHy`uVBq!ia0vp^f*{Pn1|+R>-G2co&H|6fVg?3oVGw3ym^DWND9BhG z+TD%}P*SpH_HxquP}NY}%}TKSa;I@S1;S&BUkQB<7r4 zwq4KKQsHi*?S2WHZALqed;FRsRHxm2VpX+j+m|>ybvxtS$xnN?zS!S9@#Kn$%Q6%K zHD9u{X6ES%uRP9b`h@i|pXC*WwMz`vHkYn5rru;wGd{|{Kj|42H1?c8kv P^a_KgtDnm{r-UW|4P$aD literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/ic_content_tag_original.png b/app/src/main/res/drawable-mdpi/ic_content_tag_original.png new file mode 100644 index 0000000000000000000000000000000000000000..9935750fa5f42ebbd40ef62dba519c330f8a364e GIT binary patch literal 549 zcmV+=0^0qFP)>2G$d3Z%_{)cJ0E}ZUkMZbfHkeLItHiiTM&|GGQiZL`paPAcV=A_kHiZd2hn# z>dU4H!k`IbCh$LjwFO4Sl{xpVvz4-Xn+z7#MY$BC>z#SZn2hQl7UdiLfc5_Mc zHsx>o)cmyLX8=b|?yeWrC^II1dwat&(F&Zbn}Qq>7FvB}52A*Ri=1yID3$;sIdbaG zQGOU|)5o_$YarGKONIz4QXrRR5{X6Cw~<&MJDJJi^{(YU9EZzlK}=-j)f0X}k7T@Wx^sv<2Lb0l++Ios&)>c`>dH}R4}aT|=kFK2B!q|zkXJVS*1T|h?Q-z!MW*_00E#|i60W#B?tzK0<&Xr)q{?BHSrDBMv}i^@UX-8)iF_P+poo4`S1tck!& zQJ{=ndI)4INQGV<2sePkHj*PC4YQ989mMWepz|5%|5Zl1K<)-mx~b%9vY)HTVLC5P zIdLP18>iu4FO}hc@FF`8TzjOPRpmF0|9>VqijZ*>^0$rgt#?4<>3HJ=_Zm+`Rv(bm zS}+tbp}7@v@;~d5QbDa>j~U)}9aFZrB$1FR+KLiy9L&^=0%_48&!i!a_D7)kMshOJ zSp%wX<(rkqe88q?dRg9`Pkvg$pX#KFitYERJk@@A6_~qgMWxnuzeQ{;o-B)lsjH^W zNskXbcIGM&1-S>{nL#X*pB4JBdF^{?BJXT*W04~l+et{1CPnhOy% z;g3Ye;^4??j_e$~n7t*B2J>JS1Ch6@Ewmi4K{#8O2UZOe83&WBIcAyi`!IIm(vrN{ zXa_CJ!6>|z)Nm=HAzn#3$>TsB9U+Hc1XHHn4I}KU`y|*pz0H$P69GG~-!M^Jejp=& z4aH{?D68kuUfmhr?kw7QWv1bk`#|Nnggy@3qYPyU>e@`ecOUr1HM^T#%Ch8anqCmT z^{KV-#BpsqDX7Uhvw~FUe^=4JcE + + + diff --git a/app/src/main/res/drawable/bg_audio_content_tag_free.xml b/app/src/main/res/drawable/bg_audio_content_tag_free.xml new file mode 100644 index 00000000..15c6e6f6 --- /dev/null +++ b/app/src/main/res/drawable/bg_audio_content_tag_free.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/layout/view_audio_content_card.xml b/app/src/main/res/layout/view_audio_content_card.xml index 67752e68..09db9df5 100644 --- a/app/src/main/res/layout/view_audio_content_card.xml +++ b/app/src/main/res/layout/view_audio_content_card.xml @@ -5,13 +5,92 @@ android:layout_height="wrap_content" android:orientation="vertical"> - + android:background="@drawable/bg_audio_content_card_thumbnail"> + + + + + + + + + + + + + + + + + + + + + + All items Free + Free Coming soon Points Owned diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index badcad75..a3f560b5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1159,6 +1159,7 @@ 無料 + 無料 公開予定 ポイント 所持中 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d90aa320..218eddf0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1158,6 +1158,7 @@ 전체 무료 + 무료 오픈예정 포인트 소장중 diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTagTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTagTest.kt new file mode 100644 index 00000000..61682cda --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentTagTest.kt @@ -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() + + assertEquals(emptyList(), tags.audioContentTopTags()) + assertEquals(emptyList(), tags.audioContentBottomTags()) + } +} diff --git a/docs/plan-task/20260522_오디오콘텐츠위젯태그배지.md b/docs/plan-task/20260522_오디오콘텐츠위젯태그배지.md new file mode 100644 index 00000000..00ace8c4 --- /dev/null +++ b/docs/plan-task/20260522_오디오콘텐츠위젯태그배지.md @@ -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.audioContentTopTags(): List`는 `[Original, First]` 순서를 적용한다. +- `fun Collection.audioContentBottomTags(): List`는 `[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)` 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)` 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로 대체 검증했다. diff --git a/docs/prd/20260522_오디오콘텐츠위젯태그배지_prd.md b/docs/prd/20260522_오디오콘텐츠위젯태그배지_prd.md new file mode 100644 index 00000000..97737282 --- /dev/null +++ b/docs/prd/20260522_오디오콘텐츠위젯태그배지_prd.md @@ -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)` 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 문서를 기준으로 별도 진행한다.