From 6fda122091304f6dd83273f9c6e0ee86cda87edb Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 19 May 2026 23:52:53 +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=EC=B9=B4=EB=93=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/widget/AudioContentCardSize.kt | 40 ++ .../v2/widget/AudioContentCardView.kt | 90 +++++ .../bg_audio_content_card_thumbnail.xml | 5 + .../res/layout/view_audio_content_card.xml | 41 ++ .../v2/widget/AudioContentCardSizeTest.kt | 38 ++ .../20260519_오디오콘텐츠카드컴포넌트.md | 377 ++++++++++++++++++ .../20260519_오디오콘텐츠카드컴포넌트_prd.md | 113 ++++++ 7 files changed, 704 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt create mode 100644 app/src/main/res/drawable/bg_audio_content_card_thumbnail.xml create mode 100644 app/src/main/res/layout/view_audio_content_card.xml create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSizeTest.kt create mode 100644 docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md create mode 100644 docs/prd/20260519_오디오콘텐츠카드컴포넌트_prd.md diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt new file mode 100644 index 00000000..f73bdb04 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.v2.widget + +import androidx.annotation.StyleRes +import kr.co.vividnext.sodalive.R + +sealed class AudioContentCardSize( + val cardWidthDp: Int, + val thumbnailSizeDp: Int, + val labelWidthDp: Int, + val thumbnailLabelGapDp: Int, + @get:StyleRes val titleStyleRes: Int, + @get:StyleRes val creatorStyleRes: Int +) { + data object Large : AudioContentCardSize( + cardWidthDp = 185, + thumbnailSizeDp = 185, + labelWidthDp = 185, + thumbnailLabelGapDp = 11, + titleStyleRes = R.style.Typography_Heading4, + creatorStyleRes = R.style.Typography_Body5 + ) + + data object Medium : AudioContentCardSize( + cardWidthDp = 163, + thumbnailSizeDp = 163, + labelWidthDp = 151, + thumbnailLabelGapDp = 8, + titleStyleRes = R.style.Typography_Heading4, + creatorStyleRes = R.style.Typography_Body5 + ) + + data object Small : AudioContentCardSize( + cardWidthDp = 122, + thumbnailSizeDp = 122, + labelWidthDp = 114, + thumbnailLabelGapDp = 8, + titleStyleRes = R.style.Typography_Body1, + creatorStyleRes = R.style.Typography_Caption2 + ) +} 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 new file mode 100644 index 00000000..ca8df8ca --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt @@ -0,0 +1,90 @@ +package kr.co.vividnext.sodalive.v2.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import kr.co.vividnext.sodalive.R +import kotlin.math.roundToInt + +class AudioContentCardView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private var thumbnail: ImageView? = null + private var labelContainer: LinearLayout? = null + private var titleText: TextView? = null + private var creatorText: TextView? = null + + init { + orientation = VERTICAL + } + + override fun onFinishInflate() { + super.onFinishInflate() + thumbnail = findViewById(R.id.iv_audio_content_thumbnail) + labelContainer = findViewById(R.id.ll_audio_content_label) + titleText = findViewById(R.id.tv_audio_content_title) + creatorText = findViewById(R.id.tv_audio_content_creator) + setSize(AudioContentCardSize.Medium) + } + + fun setSize(size: AudioContentCardSize) { + updateRootWidth(size.cardWidthDp.dpToPx()) + + requireNotNull(thumbnail).layoutParams = LayoutParams( + size.thumbnailSizeDp.dpToPx(), + size.thumbnailSizeDp.dpToPx() + ) + + requireNotNull(labelContainer).layoutParams = LayoutParams( + size.labelWidthDp.dpToPx(), + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = size.thumbnailLabelGapDp.dpToPx() + } + + requireNotNull(titleText).setTextAppearance(size.titleStyleRes) + requireNotNull(creatorText).apply { + setTextAppearance(size.creatorStyleRes) + layoutParams = LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = TITLE_CREATOR_GAP_DP.dpToPx() + } + } + } + + fun setContent( + title: String, + creatorName: String + ) { + requireNotNull(titleText).text = title + requireNotNull(creatorText).text = creatorName + } + + fun thumbnailView(): ImageView = requireNotNull(thumbnail) + + private fun updateRootWidth(width: Int) { + val currentLayoutParams = layoutParams + layoutParams = if (currentLayoutParams == null) { + ViewGroup.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT) + } else { + currentLayoutParams.apply { + this.width = width + this.height = ViewGroup.LayoutParams.WRAP_CONTENT + } + } + } + + private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).roundToInt() + + private companion object { + const val TITLE_CREATOR_GAP_DP = 2 + } +} diff --git a/app/src/main/res/drawable/bg_audio_content_card_thumbnail.xml b/app/src/main/res/drawable/bg_audio_content_card_thumbnail.xml new file mode 100644 index 00000000..c410cc4e --- /dev/null +++ b/app/src/main/res/drawable/bg_audio_content_card_thumbnail.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/view_audio_content_card.xml b/app/src/main/res/layout/view_audio_content_card.xml new file mode 100644 index 00000000..67752e68 --- /dev/null +++ b/app/src/main/res/layout/view_audio_content_card.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSizeTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSizeTest.kt new file mode 100644 index 00000000..20430c9d --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSizeTest.kt @@ -0,0 +1,38 @@ +package kr.co.vividnext.sodalive.v2.widget + +import kr.co.vividnext.sodalive.R +import org.junit.Assert.assertEquals +import org.junit.Test + +class AudioContentCardSizeTest { + + @Test + fun `large size matches figma contract`() { + assertEquals(185, AudioContentCardSize.Large.cardWidthDp) + assertEquals(185, AudioContentCardSize.Large.thumbnailSizeDp) + assertEquals(185, AudioContentCardSize.Large.labelWidthDp) + assertEquals(11, AudioContentCardSize.Large.thumbnailLabelGapDp) + assertEquals(R.style.Typography_Heading4, AudioContentCardSize.Large.titleStyleRes) + assertEquals(R.style.Typography_Body5, AudioContentCardSize.Large.creatorStyleRes) + } + + @Test + fun `medium size matches figma contract`() { + assertEquals(163, AudioContentCardSize.Medium.cardWidthDp) + assertEquals(163, AudioContentCardSize.Medium.thumbnailSizeDp) + assertEquals(151, AudioContentCardSize.Medium.labelWidthDp) + assertEquals(8, AudioContentCardSize.Medium.thumbnailLabelGapDp) + assertEquals(R.style.Typography_Heading4, AudioContentCardSize.Medium.titleStyleRes) + assertEquals(R.style.Typography_Body5, AudioContentCardSize.Medium.creatorStyleRes) + } + + @Test + fun `small size matches figma contract`() { + assertEquals(122, AudioContentCardSize.Small.cardWidthDp) + assertEquals(122, AudioContentCardSize.Small.thumbnailSizeDp) + assertEquals(114, AudioContentCardSize.Small.labelWidthDp) + assertEquals(8, AudioContentCardSize.Small.thumbnailLabelGapDp) + assertEquals(R.style.Typography_Body1, AudioContentCardSize.Small.titleStyleRes) + assertEquals(R.style.Typography_Caption2, AudioContentCardSize.Small.creatorStyleRes) + } +} diff --git a/docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md b/docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md new file mode 100644 index 00000000..60707307 --- /dev/null +++ b/docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md @@ -0,0 +1,377 @@ +# 오디오 콘텐츠 카드 컴포넌트 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:3800`, `20:3818`, `20:3829` 기준으로 동일 형태와 3개 크기 변형을 갖는 재사용 가능한 Audio Content Card Component를 추가한다. + +**Architecture:** XML 레이아웃 `view_audio_content_card.xml`은 썸네일과 label contents의 공통 구조만 제공한다. Kotlin custom view `AudioContentCardView`가 `AudioContentCardSize` size contract를 적용해 카드 폭, 썸네일 크기, label 폭, typography, gap을 변경한다. + +**Tech Stack:** Android XML Views, Kotlin custom View, ViewBinding/resource merge, JUnit4 local unit test. + +--- + +## 작업 목표 +- Figma 3개 오디오 콘텐츠 카드 디자인을 Android XML/Kotlin 재사용 컴포넌트로 구현한다. +- 세 컴포넌트는 구조는 동일하고 크기만 다르므로 하나의 컴포넌트와 size contract로 관리한다. +- 기존 화면 일괄 적용은 제외하고 컴포넌트 추가로 범위를 제한한다. + +## 파일 구조 +- Create: `app/src/main/res/layout/view_audio_content_card.xml` + - `AudioContentCardView` 루트, thumbnail `ImageView`, label container, title `TextView`, creator `TextView`를 정의한다. +- Create: `app/src/main/res/drawable/bg_audio_content_card_thumbnail.xml` + - 14dp corner radius thumbnail clipping 배경 또는 outline 기준 리소스를 정의한다. 실제 outline clipping은 custom view에서 처리할 수 있다. +- Modify: `app/src/main/res/values/dimens.xml` + - 필요한 경우 `spacing_2`, `spacing_11` 및 카드 전용 size dimen을 최소 추가한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt` + - `large`, `medium`, `small` size별 dimension/style contract를 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt` + - size 적용, 텍스트 바인딩, 썸네일 view 접근 API를 제공한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSizeTest.kt` + - size별 card width, thumbnail size, label width, typography contract를 검증한다. +- Modify: `docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md` + - 구현 중 체크박스와 검증 기록을 누적한다. + +## 구현 계획 + +### Task 1: 기존 리소스 및 유사 UI 확인 + +**Files:** +- Read: `app/src/main/res/values/colors.xml` +- Read: `app/src/main/res/values/dimens.xml` +- Read: `app/src/main/res/values/typography.xml` +- Read: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt` + +- [x] **Step 1: 기존 v2 custom view 패턴 확인** + +Run: `rg -n "class .*View @JvmOverloads|setTextAppearance|resources.getDimensionPixelSize" app/src/main/java/kr/co/vividnext/sodalive/v2/widget` + +Expected: `CapsuleTabBarView` 등 기존 custom view 구현 스타일을 확인한다. + +- [x] **Step 2: 필수 디자인 토큰 확인** + +Run: `rg -n "radius_14|spacing_8|gray_500|white|Typography\.Heading4|Typography\.Body1|Typography\.Body5|Typography\.Caption2" app/src/main/res/values` + +Expected: radius, color, typography 토큰이 존재함을 확인한다. + +- [x] **Step 3: 추가 dimen 필요 여부 확인** + +Run: `rg -n "spacing_2|spacing_11|185dp|163dp|151dp|122dp|114dp" app/src/main/res/values app/src/main/res/layout` + +Expected: 없는 값은 카드 전용 dimen 또는 직접값 중 하나로 최소 추가한다. + +### Task 2: AudioContentCardSize TDD + +**Files:** +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSizeTest.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt` + +- [x] **Step 1: RED - size contract 테스트 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget + +import kr.co.vividnext.sodalive.R +import org.junit.Assert.assertEquals +import org.junit.Test + +class AudioContentCardSizeTest { + + @Test + fun `large size matches figma contract`() { + assertEquals(185, AudioContentCardSize.Large.cardWidthDp) + assertEquals(185, AudioContentCardSize.Large.thumbnailSizeDp) + assertEquals(185, AudioContentCardSize.Large.labelWidthDp) + assertEquals(11, AudioContentCardSize.Large.thumbnailLabelGapDp) + assertEquals(R.style.Typography_Heading4, AudioContentCardSize.Large.titleStyleRes) + assertEquals(R.style.Typography_Body5, AudioContentCardSize.Large.creatorStyleRes) + } + + @Test + fun `medium size matches figma contract`() { + assertEquals(163, AudioContentCardSize.Medium.cardWidthDp) + assertEquals(163, AudioContentCardSize.Medium.thumbnailSizeDp) + assertEquals(151, AudioContentCardSize.Medium.labelWidthDp) + assertEquals(8, AudioContentCardSize.Medium.thumbnailLabelGapDp) + assertEquals(R.style.Typography_Heading4, AudioContentCardSize.Medium.titleStyleRes) + assertEquals(R.style.Typography_Body5, AudioContentCardSize.Medium.creatorStyleRes) + } + + @Test + fun `small size matches figma contract`() { + assertEquals(122, AudioContentCardSize.Small.cardWidthDp) + assertEquals(122, AudioContentCardSize.Small.thumbnailSizeDp) + assertEquals(114, AudioContentCardSize.Small.labelWidthDp) + assertEquals(8, AudioContentCardSize.Small.thumbnailLabelGapDp) + assertEquals(R.style.Typography_Body1, AudioContentCardSize.Small.titleStyleRes) + assertEquals(R.style.Typography_Caption2, AudioContentCardSize.Small.creatorStyleRes) + } +} +``` + +- [x] **Step 2: RED 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentCardSizeTest"` + +Expected: `Unresolved reference 'AudioContentCardSize'`로 실패한다. + +- [x] **Step 3: GREEN - 최소 size contract 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget + +import androidx.annotation.StyleRes +import kr.co.vividnext.sodalive.R + +sealed class AudioContentCardSize( + val cardWidthDp: Int, + val thumbnailSizeDp: Int, + val labelWidthDp: Int, + val thumbnailLabelGapDp: Int, + @StyleRes val titleStyleRes: Int, + @StyleRes val creatorStyleRes: Int +) { + data object Large : AudioContentCardSize( + cardWidthDp = 185, + thumbnailSizeDp = 185, + labelWidthDp = 185, + thumbnailLabelGapDp = 11, + titleStyleRes = R.style.Typography_Heading4, + creatorStyleRes = R.style.Typography_Body5 + ) + + data object Medium : AudioContentCardSize( + cardWidthDp = 163, + thumbnailSizeDp = 163, + labelWidthDp = 151, + thumbnailLabelGapDp = 8, + titleStyleRes = R.style.Typography_Heading4, + creatorStyleRes = R.style.Typography_Body5 + ) + + data object Small : AudioContentCardSize( + cardWidthDp = 122, + thumbnailSizeDp = 122, + labelWidthDp = 114, + thumbnailLabelGapDp = 8, + titleStyleRes = R.style.Typography_Body1, + creatorStyleRes = R.style.Typography_Caption2 + ) +} +``` + +- [x] **Step 4: GREEN 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentCardSizeTest"` + +Expected: `BUILD SUCCESSFUL` + +### Task 3: Audio content card XML 리소스 추가 + +**Files:** +- Create: `app/src/main/res/layout/view_audio_content_card.xml` +- Create: `app/src/main/res/drawable/bg_audio_content_card_thumbnail.xml` +- Modify: `app/src/main/res/values/dimens.xml` if needed + +- [x] **Step 1: thumbnail radius drawable 추가** + +`app/src/main/res/drawable/bg_audio_content_card_thumbnail.xml` + +```xml + + + + + +``` + +- [x] **Step 2: audio content card layout 추가** + +`app/src/main/res/layout/view_audio_content_card.xml` + +```xml + + + + + + + + + + + + +``` + +- [x] **Step 3: 추가 dimen 최소화** + +필요한 dimension은 `AudioContentCardSize`의 dp contract에서 변환해 적용한다. XML에서 직접 필요한 값이 생길 때만 `dimens.xml`에 최소 추가한다. + +### Task 4: AudioContentCardView 구현 + +**Files:** +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt` + +- [x] **Step 1: custom view 추가** + +`AudioContentCardView`는 `LinearLayout`을 상속하고 `@JvmOverloads constructor` 패턴을 따른다. + +Required API: +- `fun setSize(size: AudioContentCardSize)` +- `fun setContent(title: String, creatorName: String)` +- `fun thumbnailView(): ImageView` + +Implementation requirements: +- 기본 size는 `AudioContentCardSize.Medium`을 사용한다. +- `orientation = VERTICAL`을 보장한다. +- root layout width를 size별 `cardWidthDp`로 적용한다. +- thumbnail width/height를 size별 `thumbnailSizeDp`로 적용한다. +- label container width를 size별 `labelWidthDp`로 적용한다. +- label top margin을 size별 `thumbnailLabelGapDp`로 적용한다. +- title/creator typography는 size별 style resource를 적용한다. +- title과 creator 사이 gap은 2dp로 적용한다. +- thumbnail radius clipping은 `ViewOutlineProvider` 또는 기존 프로젝트에서 사용하는 방식으로 14dp radius를 보장한다. + +- [x] **Step 2: 텍스트 바인딩 구현** + +`setContent(title, creatorName)`은 title TextView와 creator TextView에 값을 그대로 바인딩한다. 빈 문자열 보정은 호출부 책임으로 둔다. + +- [x] **Step 3: 썸네일 바인딩 확장 지점 제공** + +이미지 로딩 라이브러리를 컴포넌트 내부에 고정하지 않도록 `thumbnailView()`로 `ImageView`를 노출한다. + +### Task 5: 검증 및 문서 기록 + +**Files:** +- Modify: `docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md` + +- [x] **Step 1: 단일 테스트 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentCardSizeTest"` + +Expected: `BUILD SUCCESSFUL` + +- [x] **Step 2: LSP 진단 실행** + +Run: `lsp_diagnostics` on modified Kotlin/XML files + +Expected: 새 오류가 없다. XML LSP가 환경에 없으면 그 사실을 검증 기록에 남긴다. + +- [x] **Step 3: 디버그 빌드 실행** + +Run: `./gradlew :app:assembleDebug` + +Expected: `BUILD SUCCESSFUL` + +- [x] **Step 4: ViewBinding 생성 확인** + +Run: `rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewAudioContentCardBinding"` + +Expected: `ViewAudioContentCardBinding` 생성 파일이 출력된다. + +- [x] **Step 5: 검증 기록 누적** + +문서 하단 `검증 기록`에 실행한 명령, 결과, 빌드 성공 여부를 한국어로 기록한다. + +## 체크리스트 +- [x] AC1: `large` 카드는 width `185dp`, thumbnail `185dp x 185dp`, label width `185dp`를 사용한다. + - QA: `AudioContentCardSizeTest`, custom view size 적용 확인 +- [x] AC2: `medium` 카드는 width `163dp`, thumbnail `163dp x 163dp`, label width `151dp`를 사용한다. + - QA: `AudioContentCardSizeTest`, custom view size 적용 확인 +- [x] AC3: `small` 카드는 width `122dp`, thumbnail `122dp x 122dp`, label width `114dp`를 사용한다. + - QA: `AudioContentCardSizeTest`, custom view size 적용 확인 +- [x] AC4: 모든 thumbnail은 radius `14dp`, `centerCrop`을 사용한다. + - QA: drawable/custom view clipping, XML `scaleType` 확인 +- [x] AC5: title은 white, creator name은 `gray_500`이며 둘 다 한 줄 말줄임 처리된다. + - QA: XML `textColor`, `maxLines`, `ellipsize` 확인 +- [x] AC6: size별 typography는 large/medium title `Typography.Heading4`, creator `Typography.Body5`, small title `Typography.Body1`, creator `Typography.Caption2`를 사용한다. + - QA: `AudioContentCardSizeTest`, custom view style 적용 확인 +- [x] AC7: 이미지 로딩 라이브러리를 컴포넌트 내부에 고정하지 않는다. + - QA: `thumbnailView()` API 및 의존성 변경 없음 확인 +- [x] AC8: 기존 화면 파일은 변경하지 않는다. + - QA: `git status --short` 변경 파일 확인 +- [x] AC9: 리소스 병합 및 디버그 빌드가 성공한다. + - QA: `./gradlew :app:assembleDebug` + +## 검증 기록 +- 2026-05-19 + - 무엇/왜/어떻게: 사용자 요청에 따라 구현 전 PRD와 구현 계획/TASK 문서만 작성했다. Figma `20:3800`, `20:3818`, `20:3829`는 동일 구조의 audio 콘텐츠 카드 크기 변형으로 문서화했다. + - 실행 명령/도구: + - `Figma_get_design_context(20:3800)` + - `Figma_get_design_context(20:3818)` + - `Figma_get_design_context(20:3829)` + - `Figma_get_screenshot(20:3800)` + - `Figma_get_screenshot(20:3818)` + - `Figma_get_screenshot(20:3829)` + - `read(docs/agent-guides/workflow-docs-commits.md)` + - `read(docs/prd/sample-prd.md)` + - `read(docs/prd/20260519_섹션타이틀컴포넌트_prd.md)` + - `read(docs/plan-task/20260519_섹션타이틀컴포넌트.md)` + - `read(docs/prd/20260519_캡슐탭바컴포넌트_prd.md)` + - `read(docs/plan-task/20260519_캡슐탭바컴포넌트.md)` + - `read(app/src/main/res/values/colors.xml)` + - `read(app/src/main/res/values/dimens.xml)` + - `read(app/src/main/res/values/typography.xml)` + - `read(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt)` + - `read(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionState.kt)` + - 결과: + - PRD 문서는 `docs/prd/20260519_오디오콘텐츠카드컴포넌트_prd.md`에 작성했다. + - 계획/TASK 문서는 `docs/plan-task/20260519_오디오콘텐츠카드컴포넌트.md`에 작성했다. + - Figma 3개 노드는 `large`, `medium`, `small` size variant로 정리했다. + - 코드, 리소스, 레이아웃 구현 파일은 변경하지 않았다. + - 실제 구현과 빌드 검증은 사용자 승인 후 계획 문서 체크리스트에 따라 진행한다. +- 2026-05-19 + - 무엇/왜/어떻게: 계획 문서에 따라 오디오 콘텐츠 카드 컴포넌트를 구현했다. `AudioContentCardSize`로 size contract를 분리하고, `AudioContentCardView`에서 카드 폭/썸네일/label/typography/gap을 size별로 적용하도록 했다. + - 실행 명령/도구: + - `rg -n "class .*View @JvmOverloads|setTextAppearance|resources.getDimensionPixelSize" app/src/main/java/kr/co/vividnext/sodalive/v2/widget` + - `rg -n "radius_14|spacing_8|gray_500|white|Typography\.Heading4|Typography\.Body1|Typography\.Body5|Typography\.Caption2" app/src/main/res/values` + - `rg -n "spacing_2|spacing_11|185dp|163dp|151dp|122dp|114dp" app/src/main/res/values app/src/main/res/layout` + - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentCardSizeTest"` (RED) + - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.AudioContentCardSizeTest"` (GREEN) + - `lsp_diagnostics` on modified Kotlin/XML files + - `rg -n "AudioContentCardView|iv_audio_content_thumbnail|clipToOutline=\"true\"|scaleType=\"centerCrop\"|tv_audio_content_title|tv_audio_content_creator|gray_500|bg_audio_content_card_thumbnail" app/src/main/res/layout/view_audio_content_card.xml app/src/main/res/drawable/bg_audio_content_card_thumbnail.xml` + - `rg -n "fun setSize|fun setContent|fun thumbnailView|AudioContentCardSize\.Medium|TITLE_CREATOR_GAP_DP|setTextAppearance" app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt` + - `./gradlew :app:assembleDebug` + - `rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewAudioContentCardBinding"` + - `git status --short` + - 결과: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt`를 추가했다. + - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt`를 추가했다. + - `app/src/main/res/layout/view_audio_content_card.xml`을 추가했다. + - `app/src/main/res/drawable/bg_audio_content_card_thumbnail.xml`을 추가했다. + - `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSizeTest.kt`를 추가했다. + - RED 실행은 `Unresolved reference 'AudioContentCardSize'`로 실패해 테스트가 신규 contract 부재를 검증함을 확인했다. + - GREEN 실행은 `BUILD SUCCESSFUL`로 완료됐다. + - 현재 환경에는 Kotlin/XML LSP 서버가 설정되어 있지 않아 `lsp_diagnostics`는 실행 불가했다. + - XML 속성 확인에서 `clipToOutline`, `centerCrop`, title/creator id, `gray_500`, thumbnail drawable 참조를 확인했다. + - `:app:assembleDebug`는 `BUILD SUCCESSFUL`로 완료됐다. + - `ViewAudioContentCardBinding.java` 생성 파일을 확인해 ViewBinding 생성 가능성을 확인했다. diff --git a/docs/prd/20260519_오디오콘텐츠카드컴포넌트_prd.md b/docs/prd/20260519_오디오콘텐츠카드컴포넌트_prd.md new file mode 100644 index 00000000..ed57ca42 --- /dev/null +++ b/docs/prd/20260519_오디오콘텐츠카드컴포넌트_prd.md @@ -0,0 +1,113 @@ +# PRD: 오디오 콘텐츠 카드 컴포넌트 + +## 1. Overview +Figma `20:3800`, `20:3818`, `20:3829` 디자인을 기준으로 XML Views 기반 화면에서 재사용할 수 있는 Audio Content Card Component를 개발한다. + +--- + +## 2. Problem +- 오디오 콘텐츠 카드가 화면마다 개별 구현되면 썸네일 크기, radius, 텍스트 영역 폭, typography, 말줄임 규칙이 달라질 수 있다. +- 제공된 3개 Figma 컴포넌트는 형태는 같고 크기만 다르므로, 별도 컴포넌트로 중복 구현하면 유지보수 비용이 커진다. +- RecyclerView, horizontal carousel, grid 등 다양한 콘텐츠 목록에서 같은 데이터 바인딩 계약으로 카드 크기만 바꿔 사용할 수 있어야 한다. + +--- + +## 3. Goals +- 동일한 구조를 갖는 오디오 콘텐츠 카드의 `large`, `medium`, `small` 크기 변형을 제공한다. +- 썸네일은 정사각형, corner radius `14dp`, center crop 기준으로 표시한다. +- 제목과 크리에이터명은 한 줄 말줄임 처리하고, 크기별 Figma typography에 맞춘다. +- 카드 크기, 썸네일 크기, 텍스트 영역 폭, 텍스트 스타일은 size contract로 한 곳에서 관리한다. +- 기존 화면에 일괄 적용하지 않고, 컴포넌트 추가와 사용 계약 문서화에 한정한다. + +--- + +## 4. Non-Goals +- 이번 범위에서는 series 타입 콘텐츠 카드, ORIGINAL 태그, first 태그, 무료 태그를 구현하지 않는다. +- 기존 RecyclerView adapter나 기존 콘텐츠 목록 화면에 신규 카드를 일괄 적용하지 않는다. +- 신규 Activity, Fragment, ViewModel을 만들지 않는다. +- Compose 컴포넌트 또는 Compose Theme를 추가하지 않는다. +- Figma에 없는 그림자, dim overlay, pressed animation, skeleton loading, badge, aspect ratio 변형은 추가하지 않는다. +- 이미지 로딩 라이브러리 선택이나 실제 URL 로딩 정책을 컴포넌트 내부에 고정하지 않는다. + +--- + +## 5. Target Users +- XML 레이아웃을 작성하거나 유지보수하는 Android 개발자. +- v2 화면에서 오디오 콘텐츠 카드 UI를 리스트/캐러셀/그리드에 재사용하려는 개발자. + +--- + +## 6. User Stories +- 개발자는 같은 오디오 콘텐츠 카드 형태를 크기만 바꿔 재사용하고 싶다. +- 개발자는 화면별 목록 구조에 맞게 `large`, `medium`, `small` 카드 크기를 선택하고 싶다. +- 개발자는 썸네일, 콘텐츠 제목, 크리에이터명을 ViewBinding 또는 custom view API로 바인딩하고 싶다. +- 개발자는 긴 제목과 크리에이터명이 레이아웃을 밀어내지 않고 한 줄 말줄임되기를 원한다. + +--- + +## 7. Core Features + +### Audio Content Card Component +Figma 3개 노드의 공통 구조를 하나의 XML + Kotlin custom view 컴포넌트로 제공한다. + +#### Requirements +- Figma large: 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-3800&m=dev +- Figma medium: 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-3818&m=dev +- Figma small: 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-3829&m=dev +- 공통 구조: 정사각형 thumbnail + 하단 label contents 영역. +- Thumbnail corner radius: `radius_14`. +- Thumbnail scale type: `centerCrop`. +- Title: `maxLines=1`, `ellipsize=end`, text color white. +- Creator name: `maxLines=1`, `ellipsize=end`, text color `gray_500`. +- Title과 creator name 사이 gap은 `2dp`로 맞춘다. 기존 `spacing_2` 토큰이 없으므로 구현 단계에서 신규 dimen 추가 또는 XML 직접값 사용 중 하나를 계획에서 명시한다. +- Thumbnail과 label 영역 사이 gap은 large는 `11dp`, medium/small은 `spacing_8` 기준으로 맞춘다. 기존 `spacing_11` 토큰이 없으므로 large gap은 구현 단계에서 신규 dimen 추가 또는 XML 직접값 사용 중 하나를 계획에서 명시한다. + +#### Size Variants +| Size | Figma node | Card width | Thumbnail | Label width | Title style | Creator style | Thumbnail-label gap | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `large` | `20:3800` | `185dp` | `185dp x 185dp` | `185dp` | `Typography.Heading4` | `Typography.Body5` | `11dp` | +| `medium` | `20:3818` | `163dp` | `163dp x 163dp` | `151dp` | `Typography.Heading4` | `Typography.Body5` | `spacing_8` | +| `small` | `20:3829` | `122dp` | `122dp x 122dp` | `114dp` | `Typography.Body1` | `Typography.Caption2` | `spacing_8` | + +#### Edge Cases +- 제목이 길면 한 줄 말줄임 처리한다. +- 크리에이터명이 길면 한 줄 말줄임 처리한다. +- 제목 또는 크리에이터명이 비어 있으면 호출부 데이터 문제로 간주하고 컴포넌트는 전달된 값을 그대로 표시한다. +- 썸네일 이미지가 없거나 로딩 실패한 경우의 placeholder 정책은 호출부 또는 이미지 로딩 계층에서 결정한다. +- size가 지정되지 않으면 `medium`을 기본값으로 사용한다. + +--- + +## 8. UX / UI Expectations +- 세 크기 모두 같은 카드 구조와 정사각형 썸네일 형태를 유지한다. +- 썸네일 radius는 모든 크기에서 14dp로 동일하다. +- `large` 카드는 카드 전체 폭과 label 폭을 185dp로 사용한다. +- `medium` 카드는 카드 폭 163dp, label 폭 151dp로 사용해 Figma처럼 label이 썸네일보다 좌우 6dp 좁게 배치된다. +- `small` 카드는 카드 폭 122dp, label 폭 114dp로 사용해 Figma처럼 label이 썸네일보다 좌우 4dp 좁게 배치된다. +- 텍스트는 어두운 배경 위 사용을 전제로 white/`gray_500` 색상 대비를 유지한다. + +--- + +## 9. Technical Constraints +- 현재 프로젝트는 XML Views + ViewBinding 기반이므로 XML 레이아웃과 Kotlin custom view 패턴을 우선한다. +- 신규 Kotlin 코드는 `kr.co.vividnext.sodalive.v2.widget` 패키지 하위에 둔다. +- 재사용 레이아웃은 `app/src/main/res/layout` 아래에 둔다. +- 색상, spacing, radius, typography는 기존 `colors.xml`, `dimens.xml`, `typography.xml` 토큰을 우선 사용한다. +- 기존 `CapsuleTabBarView`처럼 size/state 계약은 순수 Kotlin 객체 또는 enum으로 분리해 단위 테스트 가능하게 한다. +- 기존 화면의 동작이나 레이아웃을 요청 없이 변경하지 않는다. + +--- + +## 10. Metrics +- `large`, `medium`, `small` 3개 size variant의 카드 폭, 썸네일 크기, label 폭이 문서와 구현에서 일치한다. +- 제목과 크리에이터명은 한 줄 말줄임 처리된다. +- size contract 단위 테스트가 통과한다. +- Android 리소스 병합 및 디버그 빌드가 성공한다. +- 기존 화면 파일은 변경되지 않는다. + +--- + +## 11. Open Questions +- 사용자 응답 없이 진행하므로 3개 Figma 노드는 `audio` 타입 콘텐츠 카드의 크기 변형으로 문서화한다. +- Figma generated code에는 태그 옵션이 포함되어 있지만, 제공된 스크린샷과 사용자 설명은 “형태 동일, 크기만 다름”이므로 이번 문서 범위에서는 태그를 제외한다. +- `2dp`, `11dp`, `185dp`, `163dp`, `151dp`, `122dp`, `114dp`는 현재 공통 dimen 토큰에 없으므로 구현 계획에서 최소 리소스 추가 여부를 명시한다.