# 시리즈 컴포넌트 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:3875`, `20:3887`, `20:3906`, `20:3914` 기준으로 세로형 poster ratio와 ORIGINAL 태그를 지원하는 재사용 가능한 Series Content Card Component를 추가한다. **Architecture:** 기존 `AudioContentCardView`는 정사각형 오디오 카드 전용으로 유지하고, 시리즈 카드는 별도 `SeriesContentCardView`와 `SeriesContentCardSize`로 분리한다. XML 레이아웃은 thumbnail overlay 영역, ORIGINAL tag include, label contents의 공통 구조만 제공하고 Kotlin custom view가 size, tag visibility, 텍스트 바인딩, thumbnail 접근 API를 적용한다. **Tech Stack:** Android XML Views, Kotlin custom View, ViewBinding/resource merge, JUnit4 local unit test. --- ## 작업 목표 - `large` 시리즈 카드는 width `163dp`, thumbnail `163dp x 230dp`, label width `151dp`를 사용한다. - `small` 시리즈 카드는 width `122dp`, thumbnail `122dp x 172dp`, label width `114dp`를 사용한다. - thumbnail은 `radius_14`, `centerCrop`을 사용한다. - title/creator는 한 줄 말줄임 처리하고 size별 typography를 적용한다. - ORIGINAL 태그는 `ic_series_original` 아이콘과 `ORIGINAL` 텍스트를 포함하고, thumbnail 좌상단 overlay로 표시된다. - 이미지 로딩은 컴포넌트 내부에 고정하지 않고 호출부가 처리한다. - 기존 오디오 콘텐츠 카드와 기존 화면 파일은 변경하지 않는다. ## 파일 구조 - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardSize.kt` - `large`, `small` size별 card width, thumbnail width/height, label width, typography contract를 정의한다. - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardSizeTest.kt` - size별 dimension/style contract를 검증한다. - Create: `app/src/main/res/drawable/bg_series_content_thumbnail.xml` - 14dp corner radius thumbnail 배경을 정의한다. - Create: `app/src/main/res/drawable/bg_series_original_tag.xml` - `gray_900` 배경과 bottom end `8dp` radius를 가진 ORIGINAL tag 배경을 정의한다. - Create: `app/src/main/res/layout/view_series_original_tag.xml` - `ic_series_original` icon과 `ORIGINAL` 텍스트를 포함하는 101dp x 24dp 태그 layout을 정의한다. - Create: `app/src/main/res/layout/view_series_content_card.xml` - `SeriesContentCardView` 루트, thumbnail `ImageView`, ORIGINAL tag include, label container, title `TextView`, creator `TextView`를 정의한다. - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardView.kt` - size 적용, ORIGINAL tag 표시 여부, 텍스트 바인딩, 썸네일 view 접근 API를 제공한다. - Modify if missing: `app/src/main/res/drawable-mdpi/ic_series_original.png` - 현재 작업트리에 미추적 파일로 존재하므로 구현 전 상태를 확인하고, 없을 때만 디자인 에셋을 추가한다. - Modify: `docs/plan-task/20260520_시리즈컴포넌트.md` - 구현 중 체크박스와 검증 기록을 누적한다. ## 구현 계획 ### Task 1: 기존 리소스 및 유사 UI 확인 **Files:** - Read: `docs/prd/20260520_시리즈컴포넌트_prd.md` - Read: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt` - 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/res/values/colors.xml` - Read: `app/src/main/res/values/dimens.xml` - Read: `app/src/main/res/values/typography.xml` - [x] **Step 1: 기존 custom view와 리소스 패턴 확인** Run: `rg -n "AudioContentCard|CapsuleTabBar|setTextAppearance|resources.displayMetrics|radius_14|gray_500|gray_900|Typography_Heading4|Typography_Body1|Typography_Body5|Typography_Caption2" app/src/main docs` Expected: 기존 `AudioContentCardView`/`AudioContentCardSize` 패턴, 디자인 토큰, typography 리소스를 확인한다. - [x] **Step 2: ORIGINAL tag icon 상태 확인** Run: `rg --files app/src/main/res | rg "ic_series_original"` Expected: `app/src/main/res/drawable-mdpi/ic_series_original.png`가 출력된다. 출력되지 않으면 Figma asset 또는 제공된 디자인 에셋에서 `ic_series_original`을 추가한다. - [x] **Step 3: Phosphate font 보유 여부 확인** Run: `rg --files app/src/main/res/font | rg -i "phosphate|bold|medium|regular"` Expected: `phosphate` font가 있으면 ORIGINAL 텍스트에 사용한다. 없으면 기존 `@font/bold`를 fallback으로 사용하고 검증 기록에 남긴다. ### Task 2: SeriesContentCardSize TDD **Files:** - Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardSizeTest.kt` - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardSize.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 SeriesContentCardSizeTest { @Test fun `large size matches figma contract`() { assertEquals(163, SeriesContentCardSize.Large.cardWidthDp) assertEquals(163, SeriesContentCardSize.Large.thumbnailWidthDp) assertEquals(230, SeriesContentCardSize.Large.thumbnailHeightDp) assertEquals(151, SeriesContentCardSize.Large.labelWidthDp) assertEquals(8, SeriesContentCardSize.Large.thumbnailLabelGapDp) assertEquals(R.style.Typography_Heading4, SeriesContentCardSize.Large.titleStyleRes) assertEquals(R.style.Typography_Body5, SeriesContentCardSize.Large.creatorStyleRes) } @Test fun `small size matches figma contract`() { assertEquals(122, SeriesContentCardSize.Small.cardWidthDp) assertEquals(122, SeriesContentCardSize.Small.thumbnailWidthDp) assertEquals(172, SeriesContentCardSize.Small.thumbnailHeightDp) assertEquals(114, SeriesContentCardSize.Small.labelWidthDp) assertEquals(8, SeriesContentCardSize.Small.thumbnailLabelGapDp) assertEquals(R.style.Typography_Body1, SeriesContentCardSize.Small.titleStyleRes) assertEquals(R.style.Typography_Caption2, SeriesContentCardSize.Small.creatorStyleRes) } } ``` - [x] **Step 2: RED 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.SeriesContentCardSizeTest"` Expected: `Unresolved reference 'SeriesContentCardSize'`로 실패한다. - [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 SeriesContentCardSize( val cardWidthDp: Int, val thumbnailWidthDp: Int, val thumbnailHeightDp: Int, val labelWidthDp: Int, val thumbnailLabelGapDp: Int, @get:StyleRes val titleStyleRes: Int, @get:StyleRes val creatorStyleRes: Int ) { data object Large : SeriesContentCardSize( cardWidthDp = 163, thumbnailWidthDp = 163, thumbnailHeightDp = 230, labelWidthDp = 151, thumbnailLabelGapDp = 8, titleStyleRes = R.style.Typography_Heading4, creatorStyleRes = R.style.Typography_Body5 ) data object Small : SeriesContentCardSize( cardWidthDp = 122, thumbnailWidthDp = 122, thumbnailHeightDp = 172, 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.SeriesContentCardSizeTest"` Expected: `BUILD SUCCESSFUL` ### Task 3: Series content card XML 리소스 추가 **Files:** - Create: `app/src/main/res/drawable/bg_series_content_thumbnail.xml` - Create: `app/src/main/res/drawable/bg_series_original_tag.xml` - Create: `app/src/main/res/layout/view_series_original_tag.xml` - Create: `app/src/main/res/layout/view_series_content_card.xml` - [x] **Step 1: thumbnail radius drawable 추가** `app/src/main/res/drawable/bg_series_content_thumbnail.xml` ```xml ``` - [x] **Step 2: ORIGINAL tag background 추가** `app/src/main/res/drawable/bg_series_original_tag.xml` ```xml ``` - [x] **Step 3: ORIGINAL tag layout 추가** `app/src/main/res/layout/view_series_original_tag.xml` ```xml ``` Task 1에서 Phosphate font 리소스가 확인되면 `android:fontFamily="@font/bold"`를 해당 리소스로 교체한다. - [x] **Step 4: series content card layout 추가** `app/src/main/res/layout/view_series_content_card.xml` ```xml ``` ### Task 4: SeriesContentCardView 구현 **Files:** - Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardView.kt` - [x] **Step 1: custom view 추가** `SeriesContentCardView`는 `LinearLayout`을 상속하고 `@JvmOverloads constructor` 패턴을 따른다. Required API: - `fun setSize(size: SeriesContentCardSize)` - `fun setContent(title: String, creatorName: String)` - `fun setOriginalVisible(isVisible: Boolean)` - `fun thumbnailView(): ImageView` Implementation requirements: - 기본 size는 `SeriesContentCardSize.Large`를 사용한다. - `orientation = VERTICAL`을 보장한다. - root layout width를 size별 `cardWidthDp`로 적용한다. - thumbnail container width/height를 size별 `thumbnailWidthDp`/`thumbnailHeightDp`로 적용한다. - label container width를 size별 `labelWidthDp`로 적용한다. - label top margin을 size별 `thumbnailLabelGapDp`로 적용한다. - title/creator typography는 size별 style resource를 적용한다. - title과 creator 사이 gap은 2dp로 적용한다. - ORIGINAL tag는 `setOriginalVisible(false)`일 때 `GONE`, true일 때 `VISIBLE`로 표시한다. - thumbnail radius clipping은 `clipToOutline` 또는 기존 프로젝트에서 사용하는 방식으로 14dp radius를 보장한다. - [x] **Step 2: 텍스트 바인딩 구현** `setContent(title, creatorName)`은 title TextView와 creator TextView에 값을 그대로 바인딩한다. 빈 문자열 보정은 호출부 책임으로 둔다. - [x] **Step 3: 썸네일 바인딩 확장 지점 제공** 이미지 로딩 라이브러리를 컴포넌트 내부에 고정하지 않도록 `thumbnailView()`로 `ImageView`를 노출한다. ### Task 5: 검증 및 문서 기록 **Files:** - Modify: `docs/plan-task/20260520_시리즈컴포넌트.md` - [x] **Step 1: 단일 테스트 실행** Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.SeriesContentCardSizeTest"` Expected: `BUILD SUCCESSFUL` - [x] **Step 2: LSP 진단 실행** Run: `lsp_diagnostics` on modified Kotlin/XML files Expected: 새 오류가 없다. Kotlin/XML LSP가 환경에 없으면 그 사실을 검증 기록에 남긴다. - [x] **Step 3: 리소스/레이아웃 참조 확인** Run: `rg -n "SeriesContentCardView|iv_series_content_thumbnail|include_series_original_tag|ic_series_original|clipToOutline=\"true\"|scaleType=\"centerCrop\"|tv_series_content_title|tv_series_content_creator|gray_500|bg_series_content_thumbnail|bg_series_original_tag" app/src/main/res app/src/main/java/kr/co/vividnext/sodalive/v2/widget` Expected: 신규 view, layout id, drawable, icon, text color, thumbnail 속성이 출력된다. - [x] **Step 4: 디버그 빌드 실행** Run: `./gradlew :app:assembleDebug` Expected: `BUILD SUCCESSFUL` - [x] **Step 5: ViewBinding 생성 확인** Run: `rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewSeriesContentCardBinding|ViewSeriesOriginalTagBinding"` Expected: `ViewSeriesContentCardBinding`과 `ViewSeriesOriginalTagBinding` 생성 파일이 출력된다. - [x] **Step 6: 검증 기록 누적** 문서 하단 `검증 기록`에 실행한 명령, 결과, 빌드 성공 여부를 한국어로 기록한다. ## 체크리스트 - [x] AC1: `large` 카드는 width `163dp`, thumbnail `163dp x 230dp`, label width `151dp`를 사용한다. - QA: `SeriesContentCardSizeTest`, custom view size 적용 확인 - [x] AC2: `small` 카드는 width `122dp`, thumbnail `122dp x 172dp`, label width `114dp`를 사용한다. - QA: `SeriesContentCardSizeTest`, custom view size 적용 확인 - [x] AC3: 모든 thumbnail은 radius `14dp`, `centerCrop`을 사용한다. - QA: drawable/custom view clipping, XML `scaleType` 확인 - [x] AC4: title은 white, creator name은 `gray_500`이며 둘 다 한 줄 말줄임 처리된다. - QA: XML `textColor`, `maxLines`, `ellipsize` 확인 - [x] AC5: size별 typography는 large title `Typography.Heading4`, creator `Typography.Body5`, small title `Typography.Body1`, creator `Typography.Caption2`를 사용한다. - QA: `SeriesContentCardSizeTest`, custom view style 적용 확인 - [x] AC6: ORIGINAL 태그는 `ic_series_original`, `ORIGINAL` 텍스트, `gray_900` 배경, 101dp x 24dp 크기를 사용한다. - QA: `view_series_original_tag.xml`, resource reference 확인 - [x] AC7: ORIGINAL 태그 표시 여부를 API로 제어할 수 있다. - QA: `setOriginalVisible(Boolean)` 구현 확인 - [x] AC8: 이미지 로딩 라이브러리를 컴포넌트 내부에 고정하지 않는다. - QA: `thumbnailView()` API 및 의존성 변경 없음 확인 - [x] AC9: 기존 오디오 콘텐츠 카드와 기존 화면 파일은 변경하지 않는다. - QA: `git status --short` 변경 파일 확인 - [x] AC10: 리소스 병합 및 디버그 빌드가 성공한다. - QA: `./gradlew :app:assembleDebug` ## 검증 기록 - 2026-05-20 - 무엇/왜/어떻게: 사용자 요청에 따라 구현 전 PRD와 구현 계획/TASK 문서만 작성했다. Figma `20:3875`, `20:3887`, `20:3906`, `20:3914`는 시리즈 콘텐츠 카드와 ORIGINAL 태그 기준으로 문서화했다. - 실행 명령/도구: - `Figma_get_design_context(20:3875)` - `Figma_get_design_context(20:3887)` - `Figma_get_design_context(20:3906)` - `Figma_get_design_context(20:3914)` - `Figma_get_screenshot(20:3875)` - `Figma_get_screenshot(20:3887)` - `Figma_get_screenshot(20:3906)` - `Figma_get_screenshot(20:3914)` - `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(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardSize.kt)` - `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/res/values/typography.xml)` - `read(app/src/main/res/values/dimens.xml)` - `rg -n "AudioContentCard|CapsuleTabBar|radius_14|gray_500|soda_900|spacing_8|ic_series_original|Typography_Heading|Typography_Body|Typography_Caption" "app/src/main" "docs"` - `git status --short` - 결과: - PRD 문서는 `docs/prd/20260520_시리즈컴포넌트_prd.md`에 작성했다. - 계획/TASK 문서는 `docs/plan-task/20260520_시리즈컴포넌트.md`에 작성했다. - Figma `20:3875`는 large 시리즈 카드, `20:3887`은 small 시리즈 카드, `20:3906`은 ORIGINAL 태그, `20:3914`는 ORIGINAL 태그 사용 예시로 정리했다. - 사용자가 `20:3096`은 오타이고 `20:3906`이 맞다고 정정했으므로, 구현 기준은 `20:3906`으로 확정했다. - `app/src/main/res/drawable-mdpi/ic_series_original.png`는 작업트리에 미추적 파일로 존재함을 확인했으며, 이번 문서 작성 작업에서는 수정하지 않았다. - 코드, 리소스, 레이아웃 구현 파일은 변경하지 않았다. - 실제 구현과 빌드 검증은 사용자 승인 후 계획 문서 체크리스트에 따라 진행한다. - 2026-05-20 - 무엇/왜/어떻게: 사용자 정정에 따라 `20:3096`은 오타로 보고 `20:3906`을 ORIGINAL 태그 기준으로 확정한 뒤, 계획 문서에 따라 시리즈 컴포넌트를 구현했다. `SeriesContentCardSize`로 size contract를 분리하고, `SeriesContentCardView`에서 카드 폭/썸네일/label/typography/tag visibility를 size별로 적용하도록 했다. - 실행 명령/도구: - `rg -n "AudioContentCard|CapsuleTabBar|setTextAppearance|resources.displayMetrics|radius_14|gray_500|gray_900|Typography_Heading4|Typography_Body1|Typography_Body5|Typography_Caption2" app/src/main docs` - `rg --files app/src/main/res | rg "ic_series_original"` - `rg --files app/src/main/res/font | rg -i "phosphate|bold|medium|regular"` - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.SeriesContentCardSizeTest"` (RED) - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.SeriesContentCardSizeTest"` (GREEN) - `lsp_diagnostics` on modified Kotlin/XML files - `rg -n "SeriesContentCardView|iv_series_content_thumbnail|include_series_original_tag|ic_series_original|clipToOutline=\"true\"|scaleType=\"centerCrop\"|tv_series_content_title|tv_series_content_creator|gray_500|bg_series_content_thumbnail|bg_series_original_tag|setOriginalVisible|thumbnailView|phosphate_solid" app/src/main/res app/src/main/java/kr/co/vividnext/sodalive/v2/widget` - `./gradlew :app:assembleDebug` - `rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "ViewSeriesContentCardBinding|ViewSeriesOriginalTagBinding"` - `git status --short` - 결과: - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardSize.kt`를 추가했다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardView.kt`를 추가했다. - `app/src/main/res/drawable/bg_series_content_thumbnail.xml`을 추가했다. - `app/src/main/res/drawable/bg_series_original_tag.xml`을 추가했다. - `app/src/main/res/layout/view_series_content_card.xml`을 추가했다. - `app/src/main/res/layout/view_series_original_tag.xml`을 추가했다. - `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/SeriesContentCardSizeTest.kt`를 추가했다. - `app/src/main/res/drawable-mdpi/ic_series_original.png`를 ORIGINAL 태그 아이콘으로 사용했다. - RED 실행은 `Unresolved reference 'SeriesContentCardSize'`로 실패해 테스트가 신규 contract 부재를 검증함을 확인했다. - GREEN 실행은 `BUILD SUCCESSFUL`로 완료됐다. - 현재 환경에는 Kotlin/XML LSP 서버가 설정되어 있지 않아 `lsp_diagnostics`는 실행 불가했다. - 리소스 참조 확인에서 `SeriesContentCardView`, thumbnail id, ORIGINAL tag include, `ic_series_original`, `clipToOutline`, `centerCrop`, title/creator id, `gray_500`, series drawable, `setOriginalVisible`, `thumbnailView`, `phosphate_solid` 참조를 확인했다. - `:app:assembleDebug`는 `BUILD SUCCESSFUL`로 완료됐다. - `ViewSeriesContentCardBinding.java`와 `ViewSeriesOriginalTagBinding.java` 생성 파일을 확인했다.