diff --git a/docs/plan-task/20260521_피드컴포넌트.md b/docs/plan-task/20260521_피드컴포넌트.md new file mode 100644 index 00000000..f1e7b9a4 --- /dev/null +++ b/docs/plan-task/20260521_피드컴포넌트.md @@ -0,0 +1,880 @@ +# 피드 컴포넌트 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 `63:4133`, `63:4140`, `63:4142`, `63:4155` 기준으로 Rank, Live, Content, Community 4가지 Feed variant를 제공하는 재사용 가능한 Android XML Views 컴포넌트를 추가한다. + +**Architecture:** Feed 표시 데이터와 variant를 순수 Kotlin contract로 먼저 정의하고, variant별 XML layout/custom view가 해당 contract를 바인딩한다. 실제 이미지 로딩과 화면 이동은 컴포넌트 내부에서 결정하지 않고 호출부 callback 또는 노출된 `ImageView`를 통해 위임한다. + +**Tech Stack:** Android XML Views, Kotlin custom View, ViewBinding/resource merge, Spannable text styling, JUnit4 local unit test. + +--- + +## 작업 목표 +- `Rank` variant는 왼쪽 `80dp` 이미지와 오른쪽 변경 가능한 문장을 세로 가운데 정렬로 표시한다. +- `Rank` variant는 순위 텍스트 범위만 `soda_400`으로 강조한다. +- `Live` variant는 라이브 종료 안내, 라이브 제목, 상대 시간을 표시하고 제목과 시간이 겹치지 않게 한다. +- `Content` variant는 콘텐츠 이미지, 프로필, 콘텐츠명, category tag, 상대 시간을 표시한다. +- `Community` variant는 프로필, 커뮤니티 본문, 키워드, 댓글/좋아요 반응 수를 표시한다. +- 구현 중 체크박스와 검증 기록은 이 문서에 누적한다. + +## 파일 구조 +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedVariant.kt` + - `Rank`, `Live`, `Content`, `Community` variant를 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedItem.kt` + - Feed variant별 표시 데이터 계약을 sealed class로 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedRankHighlight.kt` + - Rank 문장 강조 범위와 안전한 범위 보정 정책을 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedRankTextStyler.kt` + - Rank 문장에 `soda_400` foreground span을 적용한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedWidthMode.kt` + - Feed root width를 Figma fixed width로 쓸지 부모 가용 폭으로 채울지 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedSize.kt` + - variant별 Figma root width와 width mode별 적용 width를 계산한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedContentCategory.kt` + - Content Feed category string resource id와 이미지 비율을 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedContentImageSize.kt` + - Content Feed 왼쪽 이미지의 현재 가로 크기를 유지하면서 category별 높이를 계산한다. +- Create: `app/src/main/res/layout/view_feed_rank.xml` + - Figma `63:4133` 기준 Rank layout을 정의한다. +- Create: `app/src/main/res/layout/view_feed_live.xml` + - Figma `63:4140` 기준 Live layout을 정의한다. +- Create: `app/src/main/res/layout/view_feed_content.xml` + - Figma `63:4142` 기준 Content layout을 정의한다. +- Create: `app/src/main/res/layout/view_feed_community.xml` + - Figma `63:4155` 기준 Community layout을 정의한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedRankView.kt` + - Rank 텍스트 강조, 이미지 view 노출, 클릭 callback을 처리한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedLiveView.kt` + - Live 제목/시간 제약, 프로필 이미지 view 노출, 클릭 callback을 처리한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedContentView.kt` + - Content 정보 바인딩, 콘텐츠/프로필 이미지 view 노출, 클릭 callback을 처리한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedCommunityView.kt` + - Community 본문/키워드/반응 수 바인딩, 프로필 이미지 view 노출, 클릭 callback을 처리한다. +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedAdapter.kt` + - RecyclerView에서 variant별 viewType을 생성하고 item click을 호출부에 위임한다. +- Add if missing: `app/src/main/res/drawable/bg_feed_card.xml` + - `gray_900` background + `radius_14` shape를 정의한다. +- Add if missing: `app/src/main/res/drawable/bg_feed_category_tag.xml` + - `gray_700` background + `radius_4` category tag shape를 정의한다. +- Add if missing: `app/src/main/res/drawable/ic_feed_comment.xml`, `app/src/main/res/drawable/ic_feed_like.xml` + - Community 반응 row icon을 정의한다. 기존 동일 아이콘이 있으면 재사용한다. +- Modify: `app/src/main/res/values/strings.xml` + - Live 종료 문구와 Content category 기본 label을 추가한다. +- Modify: `app/src/main/res/values-en/strings.xml`, `app/src/main/res/values-ja/strings.xml` + - 신규 string의 기존 다국어 정책에 맞는 번역을 추가한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedRankHighlightTest.kt` + - Rank 강조 범위 보정 정책을 검증한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedItemTest.kt` + - variant별 item contract와 기본값을 검증한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedRankTextStylerTest.kt` + - 순위 텍스트 범위에만 `soda_400` span이 적용되는지 검증한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedSizeTest.kt` + - 가로 스크롤/한 줄 다중 표시에서는 Figma width를, 한 줄 1개 표시에서는 부모 가용 폭을 사용하는지 검증한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedContentImageSizeTest.kt` + - Content Feed category별 이미지 비율과 높이 계산을 검증한다. +- Modify: `docs/plan-task/20260521_피드컴포넌트.md` + - 구현 중 체크박스와 검증 기록을 누적한다. + +## 구현 계획 + +### Task 1: 기존 패턴 및 Figma 기준 확인 + +**Files:** +- Read: `docs/prd/20260521_피드컴포넌트_prd.md` +- Read: `docs/prd/20260520_라이브썸네일컴포넌트_prd.md` +- Read: `docs/prd/20260519_오디오콘텐츠카드컴포넌트_prd.md` +- Read: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt` +- Read: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailDetailView.kt` +- Read: `app/src/main/res/values/colors.xml` +- Read: `app/src/main/res/values/dimens.xml` +- Read: `app/src/main/res/values/typography.xml` + +- [ ] **Step 1: 관련 기존 코드 확인** + +Run: `rg -n "AudioContentCardView|LiveThumbnailDetailView|CreatorRanking|ContentRanking|ellipsize=\"end\"|maxLines=\"1\"" app/src/main/java app/src/main/res/layout app/src/main/res/values` + +Expected: 기존 v2 custom view의 `ImageView` 노출 패턴, 1줄 ellipsis 적용 방식, ranking span/gradient 관련 구현을 확인한다. + +- [ ] **Step 2: Figma 세부 컨텍스트 재확인** + +Run tools: +- `Figma_get_design_context(63:4133)` +- `Figma_get_screenshot(63:4133)` +- `Figma_get_design_context(63:4140)` +- `Figma_get_screenshot(63:4140)` +- `Figma_get_design_context(63:4142)` +- `Figma_get_screenshot(63:4142)` +- `Figma_get_design_context(63:4155)` +- `Figma_get_screenshot(63:4155)` + +Expected: Rank, Live, Content, Community variant의 size, typography, color, radius, spacing, image placeholder 위치를 확인한다. + +- [ ] **Step 3: 구현 기준 token 정리** + +Expected token contract: +- 공통 root: `gray_900`, `radius_14`, `spacing_14` padding +- 공통 Figma root width: `374dp` +- 가로 스크롤 또는 한 줄 다중 표시 width: Figma root width `374dp` +- 한 줄 1개 표시 width: 부모 view의 가용 영역 폭 +- Rank image: `80dp x 80dp`, `radius_14`, `centerCrop` +- Rank text: `Typography.Body2`, white, highlighted rank range `soda_400` +- Live title: `Typography.Heading4`, white, 1 line ellipsis +- Live time: `Typography.Body6`, `gray_500`, end aligned +- Content image width: 현재 설정된 가로 크기 유지 +- Content image ratio: `콘텐츠=1:1`, `시리즈=163:230`, `매거진=163:218` +- Content title: `Typography.Heading4`, white, 1 line ellipsis +- Category tag: `gray_700`, `radius_4`, `spacing_4` horizontal padding, `2dp` vertical padding, `gray_100` +- Community body/keyword: `Typography.Body3`, white/`soda_400` +- Reaction icon/text: icon `18dp`, text `Typography.Body3`, `gray_500` + +### Task 2: Feed data contract TDD + +**Files:** +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedItemTest.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedVariant.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedContentCategory.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedItem.kt` + +- [ ] **Step 1: RED - variant별 item contract 테스트 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FeedItemTest { + + @Test + fun `rank item exposes rank variant and message`() { + val item = FeedItem.Rank( + feedId = "feed-rank-1", + imageUrl = "https://example.com/rank.png", + rankText = "12위", + message = "크리에이터님이 5월 2주차 크리에이터 12위를 차지하였어요!", + highlightRanges = listOf(FeedRankHighlight(start = 22, endExclusive = 25)) + ) + + assertEquals(FeedVariant.Rank, item.variant) + assertEquals("12위", item.rankText) + assertEquals(1, item.highlightRanges.size) + } + + @Test + fun `live item exposes title and created time`() { + val item = FeedItem.Live( + feedId = "feed-live-1", + creatorId = "creator-1", + creatorName = "크리에이터이름", + creatorImageUrl = "https://example.com/profile.png", + liveId = "live-1", + liveTitle = "라이브 방송 이름", + createdAtText = "2분 전" + ) + + assertEquals(FeedVariant.Live, item.variant) + assertEquals("라이브 방송 이름", item.liveTitle) + assertEquals("2분 전", item.createdAtText) + } + + @Test + fun `content item uses default category label`() { + val item = FeedItem.Content( + feedId = "feed-content-1", + creatorId = "creator-1", + creatorName = "크리에이터이름", + creatorImageUrl = "https://example.com/profile.png", + contentId = "content-1", + contentTitle = "콘텐츠 이름", + contentImageUrl = "https://example.com/content.png", + createdAtText = "2분 전" + ) + + assertEquals(FeedVariant.Content, item.variant) + assertEquals(FeedContentCategory.Content, item.category) + assertEquals("콘텐츠", item.category.label) + } + + @Test + fun `content item accepts series and magazine category`() { + val series = FeedItem.Content( + feedId = "feed-content-series", + creatorId = "creator-1", + creatorName = "크리에이터이름", + creatorImageUrl = "https://example.com/profile.png", + contentId = "series-1", + contentTitle = "시리즈 이름", + contentImageUrl = "https://example.com/series.png", + createdAtText = "2분 전", + category = FeedContentCategory.Series + ) + val magazine = series.copy( + feedId = "feed-content-magazine", + contentId = "magazine-1", + contentTitle = "매거진 이름", + contentImageUrl = "https://example.com/magazine.png", + category = FeedContentCategory.Magazine + ) + + assertEquals("시리즈", series.category.label) + assertEquals("매거진", magazine.category.label) + } + + @Test + fun `community item exposes reaction counts`() { + val item = FeedItem.Community( + feedId = "feed-community-1", + creatorId = "creator-1", + creatorName = "크리에이터 이름", + creatorImageUrl = "https://example.com/profile.png", + postId = "post-1", + bodyText = "커뮤니티 본문", + keywordText = "#키워드 #키워드", + createdAtText = "2분 전", + commentCount = 5, + likeCount = 6 + ) + + assertEquals(FeedVariant.Community, item.variant) + assertEquals(5, item.commentCount) + assertEquals(6, item.likeCount) + } +} +``` + +- [ ] **Step 2: RED 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedItemTest"` + +Expected: `Unresolved reference 'FeedItem'`, `Unresolved reference 'FeedVariant'` 또는 `Unresolved reference 'FeedContentCategory'`로 실패한다. + +- [ ] **Step 3: GREEN - 최소 data contract 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +enum class FeedVariant { + Rank, + Live, + Content, + Community +} +``` + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +enum class FeedContentCategory( + val label: String, + val ratioWidth: Int, + val ratioHeight: Int +) { + Content(label = "콘텐츠", ratioWidth = 1, ratioHeight = 1), + Series(label = "시리즈", ratioWidth = 163, ratioHeight = 230), + Magazine(label = "매거진", ratioWidth = 163, ratioHeight = 218) +} +``` + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +sealed class FeedItem(open val feedId: String, val variant: FeedVariant) { + data class Rank( + override val feedId: String, + val imageUrl: String, + val rankText: String, + val message: String, + val highlightRanges: List + ) : FeedItem(feedId, FeedVariant.Rank) + + data class Live( + override val feedId: String, + val creatorId: String, + val creatorName: String, + val creatorImageUrl: String, + val liveId: String, + val liveTitle: String, + val createdAtText: String + ) : FeedItem(feedId, FeedVariant.Live) + + data class Content( + override val feedId: String, + val creatorId: String, + val creatorName: String, + val creatorImageUrl: String, + val contentId: String, + val contentTitle: String, + val contentImageUrl: String, + val createdAtText: String, + val category: FeedContentCategory = FeedContentCategory.Content + ) : FeedItem(feedId, FeedVariant.Content) + + data class Community( + override val feedId: String, + val creatorId: String, + val creatorName: String, + val creatorImageUrl: String, + val postId: String, + val bodyText: String, + val keywordText: String, + val createdAtText: String, + val commentCount: Int, + val likeCount: Int + ) : FeedItem(feedId, FeedVariant.Community) +} +``` + +- [ ] **Step 4: GREEN 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedItemTest"` + +Expected: `BUILD SUCCESSFUL` + +### Task 3: Rank highlight contract TDD + +**Files:** +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedRankHighlightTest.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedRankHighlight.kt` + +- [ ] **Step 1: RED - 강조 범위 보정 테스트 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class FeedRankHighlightTest { + + @Test + fun `valid range remains unchanged`() { + val range = FeedRankHighlight(start = 3, endExclusive = 6).coerceIn("abc12위def") + + assertEquals(3, range?.start) + assertEquals(6, range?.endExclusive) + } + + @Test + fun `range larger than text is clamped`() { + val range = FeedRankHighlight(start = 2, endExclusive = 99).coerceIn("abc12위") + + assertEquals(2, range?.start) + assertEquals(6, range?.endExclusive) + } + + @Test + fun `empty range is ignored`() { + val range = FeedRankHighlight(start = 4, endExclusive = 4).coerceIn("abc12위") + + assertNull(range) + } + + @Test + fun `range starting after text is ignored`() { + val range = FeedRankHighlight(start = 99, endExclusive = 100).coerceIn("abc12위") + + assertNull(range) + } +} +``` + +- [ ] **Step 2: RED 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedRankHighlightTest"` + +Expected: `Unresolved reference 'FeedRankHighlight'`로 실패한다. + +- [ ] **Step 3: GREEN - 강조 범위 contract 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +data class FeedRankHighlight( + val start: Int, + val endExclusive: Int +) { + fun coerceIn(text: CharSequence): FeedRankHighlight? { + if (text.isEmpty()) return null + val safeStart = start.coerceIn(0, text.length) + val safeEnd = endExclusive.coerceIn(0, text.length) + return if (safeStart < safeEnd) FeedRankHighlight(safeStart, safeEnd) else null + } +} +``` + +- [ ] **Step 4: GREEN 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedRankHighlightTest"` + +Expected: `BUILD SUCCESSFUL` + +### Task 4: Rank text styling TDD + +**Files:** +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedRankTextStylerTest.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedRankTextStyler.kt` + +- [ ] **Step 1: RED - 순위 범위 색상 span 테스트 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +import android.graphics.Color +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import org.junit.Assert.assertEquals +import org.junit.Test + +class FeedRankTextStylerTest { + + @Test + fun `applies highlight color only to rank range`() { + val text = "크리에이터님이 12위를 차지하였어요!" + val styled = FeedRankTextStyler.style( + text = text, + highlightRanges = listOf(FeedRankHighlight(start = 8, endExclusive = 11)), + highlightColor = Color.CYAN + ) + + val spans = styled.getSpans(0, styled.length, ForegroundColorSpan::class.java) + + assertEquals(1, spans.size) + assertEquals(Color.CYAN, spans[0].foregroundColor) + assertEquals(8, styled.getSpanStart(spans[0])) + assertEquals(11, styled.getSpanEnd(spans[0])) + assertEquals(Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, styled.getSpanFlags(spans[0])) + } +} +``` + +- [ ] **Step 2: RED 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedRankTextStylerTest"` + +Expected: `Unresolved reference 'FeedRankTextStyler'`로 실패한다. + +- [ ] **Step 3: GREEN - 최소 text styler 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan + +object FeedRankTextStyler { + fun style( + text: String, + highlightRanges: List, + highlightColor: Int + ): SpannableString { + val spannable = SpannableString(text) + highlightRanges.mapNotNull { it.coerceIn(text) }.forEach { range -> + spannable.setSpan( + ForegroundColorSpan(highlightColor), + range.start, + range.endExclusive, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + return spannable + } +} +``` + +- [ ] **Step 4: GREEN 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedRankTextStylerTest"` + +Expected: `BUILD SUCCESSFUL` + +### Task 5: Feed size contract TDD + +**Files:** +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedSizeTest.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedWidthMode.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedSize.kt` + +- [ ] **Step 1: RED - width mode별 root width 테스트 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FeedSizeTest { + + @Test + fun `horizontal scroll uses figma width`() { + val size = FeedSize.from( + variant = FeedVariant.Rank, + widthMode = FeedWidthMode.FigmaFixed, + parentAvailableWidthDp = 320 + ) + + assertEquals(374, size.rootWidthDp) + } + + @Test + fun `multi item row uses figma width`() { + val size = FeedSize.from( + variant = FeedVariant.Content, + widthMode = FeedWidthMode.FigmaFixed, + parentAvailableWidthDp = 720 + ) + + assertEquals(374, size.rootWidthDp) + } + + @Test + fun `single item row uses parent available width`() { + val size = FeedSize.from( + variant = FeedVariant.Live, + widthMode = FeedWidthMode.ParentAvailable, + parentAvailableWidthDp = 328 + ) + + assertEquals(328, size.rootWidthDp) + } + + @Test(expected = IllegalArgumentException::class) + fun `parent available width must be positive for parent available mode`() { + FeedSize.from( + variant = FeedVariant.Community, + widthMode = FeedWidthMode.ParentAvailable, + parentAvailableWidthDp = 0 + ) + } +} +``` + +- [ ] **Step 2: RED 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedSizeTest"` + +Expected: `Unresolved reference 'FeedSize'` 또는 `Unresolved reference 'FeedWidthMode'`로 실패한다. + +- [ ] **Step 3: GREEN - width mode와 size contract 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +enum class FeedWidthMode { + FigmaFixed, + ParentAvailable +} +``` + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +data class FeedSize( + val rootWidthDp: Int +) { + companion object { + private const val FIGMA_ROOT_WIDTH_DP = 374 + + fun from( + variant: FeedVariant, + widthMode: FeedWidthMode, + parentAvailableWidthDp: Int + ): FeedSize { + val width = when (widthMode) { + FeedWidthMode.FigmaFixed -> FIGMA_ROOT_WIDTH_DP + FeedWidthMode.ParentAvailable -> { + require(parentAvailableWidthDp > 0) { "parentAvailableWidthDp must be greater than 0." } + parentAvailableWidthDp + } + } + return FeedSize(rootWidthDp = width) + } + } +} +``` + +- [ ] **Step 4: GREEN 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedSizeTest"` + +Expected: `BUILD SUCCESSFUL` + +### Task 6: Content image size contract TDD + +**Files:** +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedContentImageSizeTest.kt` +- Read: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedContentCategory.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedContentImageSize.kt` + +- [ ] **Step 1: RED - category별 이미지 비율 테스트 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FeedContentImageSizeTest { + + @Test + fun `content category keeps one to one ratio`() { + val size = FeedContentImageSize.from(widthDp = 163, category = FeedContentCategory.Content) + + assertEquals(163, size.widthDp) + assertEquals(163, size.heightDp) + assertEquals("콘텐츠", FeedContentCategory.Content.label) + } + + @Test + fun `series category keeps configured width and uses 163 by 230 ratio`() { + val size = FeedContentImageSize.from(widthDp = 163, category = FeedContentCategory.Series) + + assertEquals(163, size.widthDp) + assertEquals(230, size.heightDp) + assertEquals("시리즈", FeedContentCategory.Series.label) + } + + @Test + fun `magazine category keeps configured width and uses 163 by 218 ratio`() { + val size = FeedContentImageSize.from(widthDp = 163, category = FeedContentCategory.Magazine) + + assertEquals(163, size.widthDp) + assertEquals(218, size.heightDp) + assertEquals("매거진", FeedContentCategory.Magazine.label) + } + + @Test(expected = IllegalArgumentException::class) + fun `image width must be positive`() { + FeedContentImageSize.from(widthDp = 0, category = FeedContentCategory.Content) + } +} +``` + +- [ ] **Step 2: RED 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedContentImageSizeTest"` + +Expected: `Unresolved reference 'FeedContentImageSize'`로 실패한다. + +- [ ] **Step 3: GREEN - 이미지 크기 contract 추가** + +```kotlin +package kr.co.vividnext.sodalive.v2.widget.feed + +data class FeedContentImageSize( + val widthDp: Int, + val heightDp: Int +) { + companion object { + fun from(widthDp: Int, category: FeedContentCategory): FeedContentImageSize { + require(widthDp > 0) { "widthDp must be greater than 0." } + val height = (widthDp * category.ratioHeight.toFloat() / category.ratioWidth).toInt() + return FeedContentImageSize(widthDp = widthDp, heightDp = height) + } + } +} +``` + +- [ ] **Step 4: GREEN 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.FeedContentImageSizeTest"` + +Expected: `BUILD SUCCESSFUL` + +### Task 7: XML resource 추가 + +**Files:** +- Create: `app/src/main/res/drawable/bg_feed_card.xml` +- Create: `app/src/main/res/drawable/bg_feed_category_tag.xml` +- Add if missing: `app/src/main/res/drawable/ic_feed_comment.xml` +- Add if missing: `app/src/main/res/drawable/ic_feed_like.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` + +- [x] **Step 1: 공통 background drawable 추가** + +Expected `bg_feed_card.xml`: +```xml + + + + + +``` + +Expected `bg_feed_category_tag.xml`: +```xml + + + + + +``` + +- [x] **Step 2: 문자열 resource 추가** + +Expected: +- `strings.xml`: `콘텐츠` +- `strings.xml`: `feed_content_category_content`, `feed_content_category_series`, `feed_content_category_magazine` 추가 +- `values-en/strings.xml`, `values-ja/strings.xml`: 기존 번역 스타일에 맞춰 동일 key 추가 + +- [x] **Step 3: resource merge 확인** + +Run: `./gradlew :app:assembleDebug` + +Expected: `BUILD SUCCESSFUL` + +### Task 8: Rank and Live view 추가 + +**Files:** +- Create: `app/src/main/res/layout/view_feed_rank.xml` +- Create: `app/src/main/res/layout/view_feed_live.xml` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedRankView.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedLiveView.kt` + +- [x] **Step 1: Rank XML layout 추가** + +Expected: +- Root는 `bg_feed_card`, horizontal orientation, `padding=14dp`, `gravity=center_vertical`, `gap=14dp`에 해당하는 구조다. +- 왼쪽 image는 `80dp x 80dp`, `scaleType=centerCrop`, rounded clipping 가능한 구조다. +- 오른쪽 TextView는 `Typography.Body2`, white, `maxLines`는 Figma 줄 수에 맞춰 기본 2줄 허용 또는 호출부 설정 가능으로 둔다. +- Root width는 가로 스크롤/한 줄 다중 표시에서는 `374dp`, 한 줄 1개 표시에서는 부모 가용 폭을 적용할 수 있는 구조다. +- 순위 overlay TextView는 `Pattaya Regular`, `40sp`, shadow `0dp 0dp 4dp rgba(0,0,0,0.48)` 기준으로 표시한다. +- 순위 overlay TextView는 image 오른쪽 하단에서 약간 벗어난 Figma 위치를 유지하며, root/image container는 overlay를 clip하지 않는다. + +- [x] **Step 2: Live XML layout 추가** + +Expected: +- Root는 `bg_feed_card`, vertical orientation, `padding=14dp`, `gap=6dp`에 해당하는 구조다. +- 첫 row는 profile image `20dp`와 서버/호출부가 전달한 종료 안내 문구 하나의 `12sp` TextView를 가진다. +- 종료 안내 문구 TextView는 `0dp` width + constraint/weight로 폭 제약을 받고, `maxLines=1`, `ellipsize=end`를 적용한다. +- 두 번째 row는 `ConstraintLayout` 또는 동등한 제약을 사용한다. +- Live title TextView는 start to parent, end to time start, `layout_constraintHorizontal_bias=0`, `maxLines=1`, `ellipsize=end`다. +- Time TextView는 end to parent, `maxLines=1`, `ellipsize=end`다. +- Title과 time 사이 margin은 `20dp`다. +- Root width는 가로 스크롤/한 줄 다중 표시에서는 `374dp`, 한 줄 1개 표시에서는 부모 가용 폭을 적용할 수 있는 구조다. + +- [x] **Step 3: Rank/Live custom view 추가** + +Expected: +- `FeedRankView.bind(item: FeedItem.Rank)`는 `FeedRankTextStyler`로 강조 span을 적용한다. +- `FeedRankView.imageView(): ImageView`는 호출부 이미지 로딩을 위해 왼쪽 이미지 view를 반환한다. +- `FeedLiveView.bind(item: FeedItem.Live)`는 서버/호출부가 전달한 종료 안내 문구, title, time 텍스트를 바인딩한다. +- `FeedLiveView.profileImageView(): ImageView`는 호출부 이미지 로딩을 위해 프로필 image view를 반환한다. +- 두 view 모두 `setFeedSize(size: FeedSize)` 또는 동등한 API로 root width를 적용한다. +- 두 view 모두 `setOnFeedClick(listener: ((FeedItem) -> Unit)?)` 또는 variant별 listener를 제공한다. + +- [x] **Step 4: 단위 테스트와 assemble 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.*"` + +Expected: `BUILD SUCCESSFUL` + +Run: `./gradlew :app:assembleDebug` + +Expected: `BUILD SUCCESSFUL` + +### Task 9: Content and Community view 추가 + +**Files:** +- Create: `app/src/main/res/layout/view_feed_content.xml` +- Create: `app/src/main/res/layout/view_feed_community.xml` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedContentView.kt` +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedCommunityView.kt` + +- [x] **Step 1: Content XML layout 추가** + +Expected: +- Root는 `bg_feed_card`, horizontal orientation, `padding=14dp`, `gap=14dp`에 해당하는 구조다. +- 왼쪽 content image는 현재 설정된 가로 크기를 유지하고, `FeedContentImageSize`로 category별 높이를 적용할 수 있는 구조다. +- 왼쪽 content image category별 비율은 `Content=1:1`, `Series=163:230`, `Magazine=163:218`이다. +- 오른쪽 column은 width `0dp` + weight 또는 constraints로 남은 폭을 사용한다. +- Root width는 가로 스크롤/한 줄 다중 표시에서는 `374dp`, 한 줄 1개 표시에서는 부모 가용 폭을 적용할 수 있는 구조다. +- Profile row는 `20dp` 원형 image와 creator name `12sp`를 가진다. +- Content title은 `Typography.Heading4`, white, `maxLines=1`, `ellipsize=end`다. +- Bottom row는 category tag start, time end 배치이며 서로 겹치지 않는다. + +- [x] **Step 2: Community XML layout 추가** + +Expected: +- Root는 `bg_feed_card`, vertical orientation, `padding=14dp`, `gap=14dp`에 해당하는 구조다. +- Root width는 가로 스크롤/한 줄 다중 표시에서는 `374dp`, 한 줄 1개 표시에서는 부모 가용 폭을 적용할 수 있는 구조다. +- Profile row는 profile image `42dp`, creator name `Typography.Body5`, time `Typography.Body6`를 가진다. +- Body text는 `Typography.Body3`, white, line-height는 기존 typography 한계 내에서 Figma에 가장 가깝게 맞춘다. +- Keyword text는 `Typography.Body3`, `soda_400`이다. +- Reaction row는 comment icon/text, like icon/text 순서이며 gap은 Figma 기준 `15dp`다. +- Reaction row의 comment icon은 `ic_feed_community_reply`, like icon은 `ic_feed_community_heart`를 사용한다. + +- [x] **Step 3: Content/Community custom view 추가** + +Expected: +- `FeedContentView.bind(item: FeedItem.Content)`는 creator/title/category/time을 바인딩한다. +- `FeedContentView.bind(item: FeedItem.Content)`는 `item.category.labelResId`로 string resource를 조회해 하단 meta row의 category tag에 표시한다. +- `FeedContentView.bind(item: FeedItem.Content)`는 `FeedContentImageSize.from(currentImageWidth, item.category)`로 왼쪽 이미지 height를 적용한다. +- `FeedContentView.contentImageView(): ImageView`와 `profileImageView(): ImageView`를 제공한다. +- `FeedCommunityView.bind(item: FeedItem.Community)`는 creator/time/body/keyword/comment/like를 바인딩한다. +- `FeedCommunityView`는 호출부 정책에 따라 빈 body/keyword row를 숨길 수 있는 API를 제공한다. +- `FeedCommunityView.profileImageView(): ImageView`를 제공한다. +- 두 view 모두 `setFeedSize(size: FeedSize)` 또는 동등한 API로 root width를 적용한다. +- 두 view 모두 클릭 callback을 호출부에 위임한다. + +- [x] **Step 4: 단위 테스트와 assemble 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.*"` + +Expected: `BUILD SUCCESSFUL` + +Run: `./gradlew :app:assembleDebug` + +Expected: `BUILD SUCCESSFUL` + +### Task 10: FeedAdapter 추가 및 통합 검증 + +**Files:** +- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedAdapter.kt` +- Modify: `docs/plan-task/20260521_피드컴포넌트.md` + +- [x] **Step 1: Adapter 추가** + +Expected: +- `getItemViewType`은 `FeedItem.variant` 기준으로 view type을 반환한다. +- `onCreateViewHolder`는 `view_feed_rank`, `view_feed_live`, `view_feed_content`, `view_feed_community` 중 하나를 inflate한다. +- `onBindViewHolder`는 item type에 맞는 custom view `bind`를 호출한다. +- 외부 클릭 동작은 `onItemClick: (FeedItem) -> Unit` 형태로 호출부에 위임한다. +- 이미지 로딩은 adapter 내부에 고정하지 않고, 필요하면 `onBindImages(holder, item)` 같은 callback으로 호출부에 위임한다. +- adapter 또는 호출부는 표시 맥락에 따라 `FeedWidthMode.FigmaFixed`와 `FeedWidthMode.ParentAvailable`을 선택한다. +- 가로 스크롤 목록과 한 줄 다중 표시에서는 `FeedWidthMode.FigmaFixed`를 사용한다. +- 한 줄 1개 표시에서는 부모 padding/item decoration을 제외한 폭으로 `FeedWidthMode.ParentAvailable`을 사용한다. + +- [x] **Step 2: 전체 feed 단위 테스트 실행** + +Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.*"` + +Expected: `BUILD SUCCESSFUL` + +- [x] **Step 3: 관련 빌드 실행** + +Run: `./gradlew :app:assembleDebug` + +Expected: `BUILD SUCCESSFUL` + +- [x] **Step 4: 계획 문서 검증 기록 누적** + +Expected: +- 실행한 명령, 결과, 실패 시 원인과 후속 조치를 이 문서 하단 `검증 기록`에 누적한다. + +--- + +## 검증 기록 +- 2026-05-21: 문서만 작성하는 요청이므로 구현/빌드/테스트는 실행하지 않았다. Figma `63:4133`, `63:4140`, `63:4142`, `63:4155`의 design context와 screenshot을 확인해 PRD 및 구현 계획에 반영했다. +- 2026-05-21: 사용자 추가 요구사항에 따라 Feed root width 정책을 보강했다. 가로 스크롤 및 한 줄 다중 표시는 Figma `374dp` width를 사용하고, 한 줄 1개 표시는 부모 view의 가용 영역 폭을 사용하도록 PRD와 구현 계획에 반영했다. 문서만 수정했으므로 구현/빌드/테스트는 실행하지 않았다. +- 2026-05-21: 사용자 추가 요구사항에 따라 Content Feed category tag별 왼쪽 이미지 비율 정책을 보강했다. `콘텐츠`는 `1:1`, `시리즈`는 `163:230`, `매거진`은 `163:218` 비율을 사용하고, 이미지 가로 크기는 현재 설정값을 유지한 채 높이만 변경하도록 PRD와 구현 계획에 반영했다. 문서만 수정했으므로 구현/빌드/테스트는 실행하지 않았다. +- 2026-05-21: Feed 순수 contract 테스트를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.*"`로 RED를 확인했다. 이후 `FeedItem`, `FeedVariant`, `FeedRankHighlight`, `FeedRankTextStyler`, `FeedWidthMode`, `FeedSize`, `FeedContentCategory`, `FeedContentImageSize`를 추가했다. +- 2026-05-21: Rank/Live/Content/Community custom view, XML layout, drawable, string resource, `FeedAdapter`를 추가했다. 첫 Feed 테스트 실행은 `values-en/strings.xml`의 apostrophe escape 문제로 resource merge가 실패했고, `feed_live_ended_suffix`를 `\'s live broadcast has ended.`로 수정했다. +- 2026-05-21: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.*"` 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. 기존 프로젝트 Kotlin deprecation/unchecked cast warning은 출력됐지만 Feed 변경으로 인한 실패는 없었다. +- 2026-05-21: `./gradlew :app:assembleDebug` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. Kotlin LSP diagnostics는 현재 환경에 `.kt` 서버가 구성되어 있지 않아 실행할 수 없었다. +- 2026-05-21: 코드리뷰 blocking issue 및 사용자 추가 지시에 따라 Live 종료 안내 문구를 단일 서버 제공 문자열로 바인딩하고 header 폭 제약을 보강했다. Content category label은 string resource id 기반으로 변경하고 하단 meta row에 category/time을 함께 배치했다. Community reaction icon은 `ic_feed_community_reply`, `ic_feed_community_heart`로 변경하고 빈 body/keyword 숨김 API를 추가했다. Rank overlay는 Figma `Rank(type=Default, rank1=all)` 기준에 맞춰 `Pattaya Regular` 40sp, shadow, 오른쪽 하단 overflow 위치로 보정했다. +- 2026-05-21: 위 보완 사항에 대해 `FeedViewTest` 및 기존 Feed 테스트를 추가/수정했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.*"`와 `./gradlew :app:assembleDebug` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. diff --git a/docs/prd/20260521_피드컴포넌트_prd.md b/docs/prd/20260521_피드컴포넌트_prd.md new file mode 100644 index 00000000..93365d0a --- /dev/null +++ b/docs/prd/20260521_피드컴포넌트_prd.md @@ -0,0 +1,223 @@ +# PRD: 피드 컴포넌트 + +## 1. Overview +Figma `63:4133`, `63:4140`, `63:4142`, `63:4155` 디자인을 기준으로 Rank, Live, Content, Community 4가지 Feed UI를 Android XML Views 기반 재사용 컴포넌트로 문서화한다. + +--- + +## 2. Problem +- Feed UI는 같은 카드 배경과 radius를 공유하지만 variant별 내부 구조가 달라 화면별 개별 구현 시 간격, typography, 말줄임 정책이 달라질 수 있다. +- Rank Feed는 문장 안의 순위 텍스트만 강조색을 적용해야 하므로 일반 텍스트와 강조 범위를 분리한 바인딩 계약이 필요하다. +- Live Feed는 라이브 방송 이름과 상대 시간이 같은 줄에 있어 긴 제목이 시간을 침범하지 않도록 레이아웃 제약이 필요하다. +- Content와 Community Feed는 Figma 기준의 표시 요소가 다르므로 하나의 카드 컴포넌트 안에서 variant별 데이터 계약을 명확히 분리해야 한다. + +--- + +## 3. Goals +- Figma 4개 노드 기준의 Feed variant를 제공한다. + - Rank: Figma `63:4133` + - Live: Figma `63:4140` + - Content: Figma `63:4142` + - Community: Figma `63:4155` +- 공통 Feed 카드 배경, padding, radius, typography token을 Android resource 기준으로 정리한다. +- Rank Feed의 왼쪽 정사각형 영역은 실제 이미지를 표시하는 `ImageView`로 제공한다. +- Rank Feed의 오른쪽 문구는 호출부가 변경 가능해야 하며, 순위에 해당하는 텍스트 범위만 `soda_400` 강조색으로 표시한다. +- Rank Feed 전체 UI는 세로 가운데 정렬을 유지한다. +- Live Feed의 라이브 방송 이름은 한 줄 제한과 끝 말줄임을 적용한다. +- Live Feed의 라이브 방송 이름은 상대 시간 텍스트와 겹치지 않도록 제목 영역을 시간 영역 앞에서 제약한다. +- Content와 Community Feed는 Figma에 표시된 이미지, 프로필, 제목/본문, 태그, 반응 수, 시간 정보를 variant별로 바인딩할 수 있게 한다. +- Feed를 가로 스크롤 목록에 배치하거나 한 줄에 여러 개 표시할 때는 Figma에 표시된 variant별 가로 사이즈를 사용한다. +- Feed를 한 줄에 1개만 표시할 때는 Figma 고정 폭이 아니라 부모 view의 가용 영역 폭을 채운다. + +--- + +## 4. Non-Goals +- 이번 범위에서는 서버 API, DTO 필드명, pagination, 정렬, 추천 알고리즘을 변경하지 않는다. +- Feed 목록 화면 전체 개편이나 기존 화면 일괄 적용은 포함하지 않는다. +- Compose 컴포넌트 또는 Compose Theme를 추가하지 않는다. +- Figma에 없는 skeleton loading, shimmer, pressed animation, 추가 badge, 광고 영역은 추가하지 않는다. +- 이미지 로딩 라이브러리를 컴포넌트 내부에서 Coil/Glide 중 하나로 고정하지 않는다. +- Rank 순위 문구 생성 규칙은 컴포넌트 내부에서 계산하지 않고 호출부가 완성된 문장과 강조 범위를 전달한다. + +--- + +## 5. Target Users +- 메인/알림/홈 화면에서 Feed 카드를 확인하는 앱 사용자. +- XML Views와 RecyclerView 또는 ViewBinding 기반으로 Feed UI를 구현/유지보수하는 Android 개발자. + +--- + +## 6. User Stories +- 사용자는 랭킹, 라이브 종료, 콘텐츠 업로드, 커뮤니티 글 알림을 같은 Feed 카드 스타일 안에서 빠르게 구분하고 싶다. +- 사용자는 긴 라이브 제목이나 콘텐츠 제목이 있어도 카드 레이아웃이 깨지지 않기를 기대한다. +- 개발자는 4가지 Feed variant를 같은 컴포넌트 계약으로 재사용하고 싶다. +- 개발자는 Rank Feed 문구를 상황별로 바꾸면서 순위 텍스트만 강조하고 싶다. + +--- + +## 7. Core Features + +### Feed Component +Feed 카드는 `Rank`, `Live`, `Content`, `Community` 4가지 variant를 가진다. + +#### Figma References +- Rank Feed: 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=63-4133&m=dev +- Live Feed: 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=63-4140&m=dev +- Content Feed: 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=63-4142&m=dev +- Community Feed: 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=63-4155&m=dev + +#### Common Token Requirements +- Root background는 `gray_900` (`#202020`)을 사용한다. +- Root padding은 `spacing_14` (`14dp`)를 기준으로 한다. +- Root radius는 `radius_14` (`14dp`)를 기준으로 한다. +- 카드 내부 gap은 variant별 Figma 값에 맞춰 `spacing_4`, `spacing_6`, `spacing_8`, `spacing_14`, `spacing_20`을 우선 사용한다. +- 모든 이미지 영역은 실제 이미지 URL을 바인딩할 수 있는 `ImageView`로 제공하고, 이미지 로딩 실패/placeholder 정책은 호출부가 결정한다. +- 장식 아이콘은 접근성 노출이 필요하지 않으면 `contentDescription=@null`로 둔다. + +#### Size Policy Requirements +- Feed root width는 사용 맥락에 따라 결정한다. +- 가로 스크롤 목록에서 사용하는 Feed item은 Figma에 표시된 variant별 root width를 사용한다. +- Grid 또는 flex row처럼 한 줄에 여러 Feed item을 표시하는 경우에도 각 item은 Figma에 표시된 variant별 root width를 사용한다. +- 한 줄에 Feed item을 1개만 표시하는 경우 root width는 부모 view의 가용 영역을 채운다. +- 부모 view의 가용 영역은 부모 width에서 부모 padding과 item decoration 간격을 제외한 폭이다. +- 한 줄 1개 모드에서도 내부 image size, padding, radius, typography, icon size는 Figma 기준값을 유지한다. +- 한 줄 1개 모드에서 늘어나는 영역은 텍스트 column, 본문 영역, 제목 영역처럼 남은 폭을 사용하는 content 영역이다. +- 구현에서는 `WrapContent` 또는 Figma fixed width 모드와 `MatchParent` 또는 parent available width 모드를 명시적으로 구분해야 한다. + +#### Variant Requirements +| Variant | Figma node | Figma root width | Layout | Main elements | +| --- | --- | --- | --- | --- | +| `Rank` | `63:4133` | `374dp` | 가로 배치, 세로 가운데 정렬 | 80dp 정사각형 이미지, 순위 overlay, 변경 가능한 문장 | +| `Live` | `63:4140` | `374dp` | 세로 배치 | 프로필/종료 문구 row, 라이브 제목/상대 시간 row | +| `Content` | `63:4142` | `374dp` | 가로 배치 | 122dp 콘텐츠 이미지, 프로필, 콘텐츠명, 카테고리 태그, 상대 시간 | +| `Community` | `63:4155` | `374dp` | 세로 배치 | 프로필 row, 커뮤니티 본문, 키워드, 댓글/좋아요 반응 수 | + +### Rank Feed Requirements +- 왼쪽 정사각형은 실제 이미지를 표시하는 `ImageView`다. +- 이미지 영역은 `80dp x 80dp`, `radius_14`, `centerCrop`을 기준으로 한다. +- 이미지 위에는 Figma `Rank(type=Default, rank1=all)` 기준의 순위 숫자 overlay를 표시한다. +- 순위 숫자 overlay는 `Pattaya Regular`, `40sp`, white-to-`#EEEEEE` 계열 표현에 가장 가깝게 표시하고, 그림자는 `0dp 0dp 4dp rgba(0,0,0,0.48)` 기준으로 둔다. +- 순위 숫자 overlay는 이미지 오른쪽 하단에 배치하되 Figma처럼 이미지 영역을 약간 벗어나 보일 수 있도록 image container와 root는 해당 overlay를 자르지 않는다. +- 오른쪽 텍스트는 호출부가 변경 가능한 문자열이어야 한다. +- 오른쪽 텍스트의 기본 style은 `Pretendard Variable Medium`, `16sp`, line-height `1.45`, white다. +- `1위`, `10위`, `30위`처럼 순위를 나타내는 텍스트 범위만 `soda_400` (`#00BDF7`)로 표시한다. +- 순위 강조 범위는 컴포넌트가 문자열에서 추론하지 않고 호출부가 `highlightRanges` 또는 동등한 계약으로 전달한다. +- 전체 root는 `items_center`에 해당하는 세로 가운데 정렬을 유지한다. + +### Live Feed Requirements +- 첫 번째 row는 `20dp` 원형 프로필 이미지와 서버/호출부가 전달한 종료 안내 문구를 하나의 `12sp` TextView로 표시한다. +- 종료 안내 문구는 creator name과 suffix를 컴포넌트 내부에서 조합하지 않고, 서버/호출부가 내려준 완성 문자열을 그대로 표시한다. +- 종료 안내 문구 TextView는 폭 제약을 받아 한 줄 제한과 끝 말줄임을 적용하며 카드 바깥으로 넘치지 않아야 한다. +- 두 번째 row는 라이브 방송 이름과 상대 시간을 표시한다. +- 라이브 방송 이름은 `Typography.Heading4` 또는 `18sp bold`, white, `maxLines=1`, `ellipsize=end`를 적용한다. +- 상대 시간은 `Typography.Body6` 또는 `14sp regular`, `gray_500`, 오른쪽 정렬을 기준으로 한다. +- 라이브 방송 이름과 상대 시간 사이에는 최소 `20dp` gap을 둔다. +- 라이브 방송 이름 영역은 상대 시간 영역 앞에서 폭이 제한되어야 하며, 긴 제목이 `2분 전`, `10분 전` 같은 시간 텍스트와 겹치면 안 된다. + +### Content Feed Requirements +- 왼쪽 콘텐츠 이미지는 category tag에 따라 높이가 달라진다. +- 왼쪽 콘텐츠 이미지의 가로 크기는 현재 설정된 값을 유지하고, category별 비율에 따라 높이만 계산한다. +- category tag에 들어갈 수 있는 값은 `콘텐츠`, `시리즈`, `매거진`이다. +- `콘텐츠` category는 `1:1` 비율로 표시한다. +- `시리즈` category는 `163:230` 비율로 표시한다. +- `매거진` category는 `163:218` 비율로 표시한다. +- category별 이미지 높이 계산은 `height = imageWidth * ratioHeight / ratioWidth`를 기준으로 한다. +- 이미지 영역은 모든 category에서 `radius_14`, `centerCrop`을 기준으로 한다. +- 오른쪽 column은 프로필 row, 콘텐츠명, 하단 메타 row를 가진다. +- 프로필 row는 `20dp` 원형 프로필 이미지와 `12sp` creator name을 표시한다. +- 콘텐츠명은 `Typography.Heading4` 또는 `18sp bold`, white, `maxLines=1`, `ellipsize=end`를 적용한다. +- 하단 메타 row는 왼쪽 category tag와 오른쪽 상대 시간을 표시한다. +- category tag는 `gray_700` background, `radius_4`, horizontal padding `4dp`, vertical padding `2dp`, text `14sp regular`, `gray_100`을 기준으로 한다. +- 기본 category label은 `콘텐츠`이며, 표시 문자열은 `Content`, `Series`, `Magazine` 별 string resource를 사용한다. +- 상대 시간은 `14sp regular`, `gray_500`, 오른쪽 정렬을 기준으로 한다. + +### Community Feed Requirements +- 상단 profile row는 `42dp` 원형 프로필 이미지, creator name, 상대 시간을 표시한다. +- creator name은 `Typography.Body5` 또는 `14sp medium`, white를 기준으로 한다. +- 상대 시간은 `Typography.Body6` 또는 `14sp regular`, `gray_500`을 기준으로 한다. +- 본문은 `Typography.Body3` 또는 `16sp regular`, white, line-height `1.45`를 기준으로 한다. +- 본문 영역 폭은 카드 내부 폭을 채우며, Figma 기준 `346dp`를 기준값으로 삼되 부모 폭이 달라지면 내부 padding을 제외한 가용 폭을 사용한다. +- 키워드 영역은 `Typography.Body3` 또는 `16sp regular`, `soda_400`을 기준으로 한다. +- 반응 row는 댓글 수와 좋아요 수를 표시하고, icon size는 `18dp`, 텍스트는 `16sp regular`, `gray_500`을 기준으로 한다. +- 댓글 아이콘은 `ic_feed_community_reply`, 좋아요 아이콘은 `ic_feed_community_heart` 리소스를 사용한다. +- 본문과 키워드가 비어 있으면 전달된 값 그대로 표시하되, 호출부 정책에 따라 빈 row를 숨길 수 있는 API를 제공한다. + +#### Data Contract Requirements +- 모든 Feed item은 `feedId`와 `variant`를 포함해야 한다. +- 시간 표시가 있는 `Live`, `Content`, `Community` item은 `createdAtText`를 포함해야 한다. +- Rank 데이터는 다음 정보를 포함해야 한다. + - `imageUrl`: 왼쪽 정사각형 이미지 URL. + - `rankText`: 이미지 overlay 또는 접근성에 사용할 순위 문자열. + - `message`: 오른쪽 전체 문장. + - `highlightRanges`: 순위 텍스트만 강조할 문자열 범위 목록. +- Live 데이터는 다음 정보를 포함해야 한다. + - `creatorId`, `creatorName`, `creatorImageUrl`. + - `liveId`, `liveTitle`. + - `endedMessage`: 서버/호출부가 내려준 완성된 라이브 종료 안내 문구. +- Content 데이터는 다음 정보를 포함해야 한다. + - `creatorId`, `creatorName`, `creatorImageUrl`. + - `contentId`, `contentTitle`, `contentImageUrl`. + - `category`: `Content`, `Series`, `Magazine` 중 하나. 표시 label은 각각 `콘텐츠`, `시리즈`, `매거진`이다. +- Community 데이터는 다음 정보를 포함해야 한다. + - `creatorId`, `creatorName`, `creatorImageUrl`. + - `postId`, `bodyText`, `keywordText`. + - `commentCount`, `likeCount`. + +#### Edge Cases +- 이미지 URL이 비어 있으면 호출부 이미지 로딩 정책에 따른 placeholder 또는 빈 이미지를 표시한다. +- Rank `message`가 비어 있으면 오른쪽 텍스트 영역은 빈 문자열로 유지한다. +- Rank `highlightRanges`가 문자열 범위를 벗어나면 구현 단계에서 안전하게 무시하거나 clamp하는 정책을 단위 테스트로 고정한다. +- Live `liveTitle`이 매우 길면 한 줄 말줄임 처리하고 상대 시간은 항상 보존한다. +- 상대 시간 문자열이 길어도 한 줄로 표시하며, 제목/본문 영역이 해당 영역을 침범하지 않는다. +- Content `contentTitle`이 길면 한 줄 말줄임 처리한다. +- Content `category`가 서버/호출부에서 누락되면 기본값은 `Content`로 간주해 `콘텐츠` label과 `1:1` 이미지 비율을 적용한다. +- Community 본문이 길면 Figma처럼 여러 줄 표시를 허용하되, 최대 줄 수 제한은 호출 화면 정책에 따른다. +- 댓글 수 또는 좋아요 수가 없으면 0으로 표시하거나 호출부가 전달한 문자열을 그대로 표시하는 정책 중 하나를 구현 계획에서 고정한다. + +--- + +## 8. UX / UI Expectations +- 4가지 Feed 모두 어두운 배경 위에서 사용하는 것을 전제로 한다. +- Root 카드의 배경, radius, padding은 variant 간 일관성을 유지한다. +- 가로 스크롤 또는 한 줄 다중 표시에서는 Figma `374dp` root width를 유지한다. +- 한 줄 1개 표시에서는 부모 view의 가용 폭을 채우되 내부 고정 요소 크기는 유지한다. +- Rank Feed는 이미지와 텍스트가 한 줄 카드 안에서 세로 가운데 정렬되어야 한다. +- Live Feed의 제목은 시간과 겹치지 않고, 시간이 항상 오른쪽에서 읽혀야 한다. +- Content Feed는 왼쪽 큰 썸네일과 오른쪽 정보 column의 수직 배치가 Figma와 일치해야 한다. +- Content Feed의 왼쪽 이미지는 category가 바뀌어도 가로 크기를 유지하고, `콘텐츠`, `시리즈`, `매거진` 비율에 따라 높이만 변경되어야 한다. +- Community Feed는 프로필, 본문, 키워드, 반응 row의 세로 흐름이 유지되어야 한다. +- 모든 터치 동작은 컴포넌트 내부에서 목적지를 결정하지 않고 호출부 callback으로 위임한다. + +--- + +## 9. Technical Constraints +- 현재 프로젝트는 Android XML Views + Kotlin custom View + ViewBinding/resource 기반이므로 XML layout과 Kotlin custom view 패턴을 우선한다. +- 신규 Kotlin 코드는 `app/src/main/java/kr/co/vividnext/sodalive/v2` 하위 패키지에 작성한다. +- 재사용 가능한 위젯은 `kr.co.vividnext.sodalive.v2.widget.feed` 하위에 둔다. +- 색상, spacing, radius, typography는 기존 `colors.xml`, `dimens.xml`, `typography.xml` token을 우선 재사용한다. +- 기존 `AudioContentCardView`, `LiveThumbnail*View`, `CreatorRanking*View`, `ContentRanking*View` 패턴처럼 표시 데이터 contract와 custom view 바인딩을 분리한다. +- 이미지 로딩은 컴포넌트 내부에 고정하지 않고 `ImageView`를 노출하거나 adapter/caller가 수행한다. +- 기존 화면 파일은 요청 없이 변경하지 않는다. + +--- + +## 10. Metrics +- Rank, Live, Content, Community 4개 variant가 Figma 노드와 주요 배치, 색상, typography, spacing 계약을 만족한다. +- Rank Feed의 왼쪽 영역은 실제 이미지로 바인딩 가능하다. +- Rank Feed의 오른쪽 문구는 호출부에서 변경 가능하고, 순위 텍스트 범위만 `soda_400`으로 표시된다. +- Rank Feed root는 세로 가운데 정렬을 유지한다. +- Live Feed의 라이브 방송 이름은 `maxLines=1`, `ellipsize=end`이며 상대 시간과 겹치지 않는다. +- Content Feed의 콘텐츠명은 한 줄 말줄임 처리된다. +- Content Feed의 왼쪽 이미지는 `콘텐츠=1:1`, `시리즈=163:230`, `매거진=163:218` 비율을 만족한다. +- Community Feed의 댓글/좋아요 반응 수가 Figma 순서와 스타일로 표시된다. +- 가로 스크롤 및 한 줄 다중 표시에서는 Feed root width가 Figma 기준 `374dp`로 적용된다. +- 한 줄 1개 표시에서는 Feed root width가 부모 view의 가용 영역 폭으로 적용된다. +- 관련 unit test와 Android resource merge/build가 성공한다. + +--- + +## 11. Open Questions +- 사용자 요청이 “문서만 작성”이므로 이번 작업에서는 구현 파일을 만들지 않는다. +- Content와 Community variant는 별도 추가 조건이 없으므로 Figma `get_design_context`와 screenshot 기준으로 요구사항을 확정한다. +- 실제 적용 화면과 API/DTO 매핑은 구현 단계 또는 호출부 작업에서 결정한다. +- Community 본문 최대 줄 수는 Figma에서 긴 본문이 여러 줄로 표시되어 있으므로 컴포넌트 기본값은 제한하지 않고, 호출 화면 정책으로 제한할 수 있게 둔다.