Files
sodalive-android/docs/plan-task/20260521_피드컴포넌트.md

38 KiB

피드 컴포넌트 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 테스트 추가

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 추가
package kr.co.vividnext.sodalive.v2.widget.feed

enum class FeedVariant {
    Rank,
    Live,
    Content,
    Community
}
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)
}
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 - 강조 범위 보정 테스트 추가

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 추가
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 테스트 추가

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 추가
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 테스트 추가

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 추가
package kr.co.vividnext.sodalive.v2.widget.feed

enum class FeedWidthMode {
    FigmaFixed,
    ParentAvailable
}
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별 이미지 비율 테스트 추가

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 추가
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

  • Step 1: 공통 background drawable 추가

Expected bg_feed_card.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 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>
  • 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 추가

  • 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

  • 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하지 않는다.

  • 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개 표시에서는 부모 가용 폭을 적용할 수 있는 구조다.

  • 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를 제공한다.

  • 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

  • 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 배치이며 서로 겹치지 않는다.

  • 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를 사용한다.

  • 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(): ImageViewprofileImageView(): 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을 호출부에 위임한다.

  • 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

  • Step 1: Adapter 추가

Expected:

  • getItemViewTypeFeedItem.variant 기준으로 view type을 반환한다.

  • onCreateViewHolderview_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.FigmaFixedFeedWidthMode.ParentAvailable을 선택한다.

  • 가로 스크롤 목록과 한 줄 다중 표시에서는 FeedWidthMode.FigmaFixed를 사용한다.

  • 한 줄 1개 표시에서는 부모 padding/item decoration을 제외한 폭으로 FeedWidthMode.ParentAvailable을 사용한다.

  • Step 2: 전체 feed 단위 테스트 실행

Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.feed.*"

Expected: BUILD SUCCESSFUL

  • Step 3: 관련 빌드 실행

Run: ./gradlew :app:assembleDebug

Expected: BUILD SUCCESSFUL

  • 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을 확인했다.