docs(feed): 피드 컴포넌트 요구사항을 문서화한다
This commit is contained in:
880
docs/plan-task/20260521_피드컴포넌트.md
Normal file
880
docs/plan-task/20260521_피드컴포넌트.md
Normal file
@@ -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<FeedRankHighlight>
|
||||
) : 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<FeedRankHighlight>,
|
||||
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
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/gray_900" />
|
||||
<corners android:radius="@dimen/radius_14" />
|
||||
</shape>
|
||||
```
|
||||
|
||||
Expected `bg_feed_category_tag.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/gray_700" />
|
||||
<corners android:radius="@dimen/radius_4" />
|
||||
</shape>
|
||||
```
|
||||
|
||||
- [x] **Step 2: 문자열 resource 추가**
|
||||
|
||||
Expected:
|
||||
- `strings.xml`: `<string name="feed_content_category_default">콘텐츠</string>`
|
||||
- `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`을 확인했다.
|
||||
Reference in New Issue
Block a user