Files
sodalive-android/docs/plan-task/20260520_라이브썸네일컴포넌트.md

33 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 24:4999, 24:5017 기준으로 현재 라이브 중인 상태를 표시하는 재사용 가능한 라이브 썸네일 컴포넌트를 추가한다.

Architecture: 라이브 썸네일 표시 데이터와 variant를 순수 Kotlin contract로 먼저 정의하고, Android XML layout과 custom view가 해당 contract를 바인딩한다. 실제 이미지 로딩은 컴포넌트가 직접 수행하지 않고 ImageView를 노출해 기존 호출부의 Coil/Glide 정책을 유지한다.

Tech Stack: Android XML Views, Kotlin custom View, ViewBinding/resource merge, JUnit4 local unit test.


작업 목표

  • Simple variant는 Figma 24:4999 기준 세로형 라이브 프로필 썸네일로 구현한다.
  • Detail variant는 Figma 24:5017 기준 가로형 라이브 썸네일로 구현한다.
  • Figma placeholder 이미지 영역에는 실제 imageUrl을 로드할 수 있는 ImageView를 제공한다.
  • 모든 텍스트는 1줄 제한과 끝 말줄임 처리를 적용한다.
  • 터치 동작은 호출부 callback으로 위임한다.

파일 구조

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailVariant.kt
    • Simple, Detail variant를 정의한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailItem.kt
    • 라이브 썸네일 UI에 필요한 최소 데이터 계약을 정의한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailSize.kt
    • Figma 기준 variant별 기본 크기와 텍스트 영역 width를 정의한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailSimpleView.kt
    • 세로형 variant의 텍스트 바인딩, 이미지 view 노출, 터치 callback을 처리한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailDetailView.kt
    • 가로형 variant의 텍스트 바인딩, 이미지 view 노출, 터치 callback을 처리한다.
  • Create: app/src/main/res/layout/view_live_thumbnail_simple.xml
    • Figma 24:4999 기준 세로형 layout을 정의한다.
  • Create: app/src/main/res/layout/view_live_thumbnail_detail.xml
    • Figma 24:5017 기준 가로형 layout을 정의한다.
  • Add if missing: app/src/main/res/drawable/bg_live_thumbnail_ring.xml
    • Simple 외곽 ocean-blue ring drawable을 정의한다.
  • Add if missing: app/src/main/res/drawable/bg_live_thumbnail_badge.xml
    • LIVE badge black background + #62CFFF stroke + pill radius를 정의한다.
  • Add if missing: app/src/main/res/drawable/bg_live_thumbnail_badge_capsule.xml
    • Detail LIVE badge용 black background + pill radius, stroke 없는 capsule을 정의한다.
  • Add if missing: app/src/main/res/drawable/bg_live_thumbnail_dot.xml
    • LIVE badge 내부 red dot을 정의한다.
  • Add if missing: app/src/main/res/drawable/bg_live_thumbnail_detail.xml
    • Detail root gray_900 background + #62CFFF stroke + pill radius를 정의한다.
  • Read: app/src/main/res/values/colors.xml
    • color_62cfff resource가 있는지 확인하고, 없으면 기존 colors.xml에 추가해 live thumbnail drawable에서 참조한다.
  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailItemTest.kt
    • 데이터 trimming 없이 원문을 보존하고 display 문자열 fallback을 검증한다.
  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailSizeTest.kt
    • variant별 Figma 기준 크기 계약을 검증한다.
  • Modify: docs/plan-task/20260520_라이브썸네일컴포넌트.md
    • 구현 중 체크박스와 검증 기록을 누적한다.

구현 계획

Task 1: 기존 패턴 및 Figma 기준 확인

Files:

  • Read: docs/prd/20260520_라이브썸네일컴포넌트_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/creatorranking/CreatorRankingLargeCardView.kt

  • Read: app/src/main/res/layout/item_home_live.xml

  • Read: app/src/main/res/layout/item_live_now.xml

  • Read: app/src/main/res/values/colors.xml

  • Read: app/src/main/res/values/typography.xml

  • Step 1: 관련 기존 코드 확인

Run: rg -n "AudioContentCardView|CreatorRankingLargeCardView|iv_profile|img_live|ellipsize=\"end\"|maxLines=\"1\"" app/src/main/java app/src/main/res/layout app/src/main/res/values

Expected: 기존 v2 custom view의 imageView() 노출 패턴, 기존 라이브 profile/LIVE badge 사용처, 1줄 ellipsis 적용 예시를 확인한다.

  • Step 2: Figma 세부 컨텍스트 재확인

Run tools:

  • Figma_get_design_context(24:4999)
  • Figma_get_screenshot(24:4999)
  • Figma_get_design_context(24:5017)
  • Figma_get_screenshot(24:5017)

Expected: SimpleDetail variant의 size, typography, color, radius, spacing, image placeholder 위치를 확인한다.

  • Step 3: 구현 기준 token 정리

Expected token contract:

  • Simple root width: 70dp
  • Simple profile area: 70dp x 76dp
  • Simple image: 58dp x 58dp, circle crop, start/top 6dp
  • Simple ring: 70dp x 70dp, ocean-blue stroke/gradient
  • Simple LIVE badge: black background, #62CFFF stroke 2dp, height 18dp, radius pill
  • Detail LIVE badge: black background, stroke 없음, height 18dp, radius pill
  • LIVE badge dot: 8dp x 8dp
  • Simple creator name: @style/Typography.Body5, white, center, 1 line ellipsis
  • Detail root: 266dp x 99dp, gray_900, #62CFFF stroke 2dp, radius 90dp
  • Detail image: 75dp x 75dp, circle crop, start 10dp, vertical center
  • Detail text column: start 93dp, width 149dp, vertical center, gap 4dp
  • Detail title: @style/Typography.Heading4, white, 1 line ellipsis
  • Detail creator/time: @style/Typography.Body6, gray_500, 1 line ellipsis

Task 2: Live thumbnail data contract TDD

Files:

  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailItemTest.kt

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailItem.kt

  • Step 1: RED - item display contract 테스트 추가

package kr.co.vividnext.sodalive.v2.widget.livethumbnail

import org.junit.Assert.assertEquals
import org.junit.Test

class LiveThumbnailItemTest {

    @Test
    fun `item keeps title creator and live start time text`() {
        val item = LiveThumbnailItem(
            liveId = 10L,
            creatorId = 20L,
            imageUrl = "https://example.com/profile.png",
            title = "라이브 제목",
            creatorName = "크리에이터이름",
            liveStartTimeText = "00:30"
        )

        assertEquals("라이브 제목", item.title)
        assertEquals("크리에이터이름", item.creatorName)
        assertEquals("00:30", item.liveStartTimeText)
    }

    @Test
    fun `blank live start time remains blank`() {
        val item = LiveThumbnailItem(
            liveId = 10L,
            creatorId = 20L,
            imageUrl = "https://example.com/profile.png",
            title = "라이브 제목",
            creatorName = "크리에이터이름",
            liveStartTimeText = ""
        )

        assertEquals("", item.liveStartTimeText)
    }
}
  • Step 2: RED 실행

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

Expected: Unresolved reference 'LiveThumbnailItem'로 실패한다.

  • Step 3: GREEN - 최소 data contract 추가
package kr.co.vividnext.sodalive.v2.widget.livethumbnail

data class LiveThumbnailItem(
    val liveId: Long,
    val creatorId: Long,
    val imageUrl: String,
    val title: String,
    val creatorName: String,
    val liveStartTimeText: String
)
  • Step 4: GREEN 실행

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

Expected: BUILD SUCCESSFUL

Task 3: Variant size contract TDD

Files:

  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailSizeTest.kt

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailVariant.kt

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailSize.kt

  • Step 1: RED - variant별 Figma 크기 테스트 추가

package kr.co.vividnext.sodalive.v2.widget.livethumbnail

import org.junit.Assert.assertEquals
import org.junit.Test

class LiveThumbnailSizeTest {

    @Test
    fun `simple variant uses figma base size`() {
        val size = LiveThumbnailSize.from(LiveThumbnailVariant.Simple)

        assertEquals(70, size.rootWidthDp)
        assertEquals(76, size.profileAreaHeightDp)
        assertEquals(58, size.imageSizeDp)
        assertEquals(70, size.textWidthDp)
    }

    @Test
    fun `detail variant uses figma base size`() {
        val size = LiveThumbnailSize.from(LiveThumbnailVariant.Detail)

        assertEquals(266, size.rootWidthDp)
        assertEquals(99, size.rootHeightDp)
        assertEquals(75, size.imageSizeDp)
        assertEquals(149, size.textWidthDp)
    }
}
  • Step 2: RED 실행

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

Expected: Unresolved reference 'LiveThumbnailSize' 또는 Unresolved reference 'LiveThumbnailVariant'로 실패한다.

  • Step 3: GREEN - variant와 size contract 추가
package kr.co.vividnext.sodalive.v2.widget.livethumbnail

enum class LiveThumbnailVariant {
    Simple,
    Detail
}
package kr.co.vividnext.sodalive.v2.widget.livethumbnail

data class LiveThumbnailSize(
    val rootWidthDp: Int,
    val rootHeightDp: Int?,
    val profileAreaHeightDp: Int?,
    val imageSizeDp: Int,
    val textWidthDp: Int
) {
    companion object {
        fun from(variant: LiveThumbnailVariant): LiveThumbnailSize = when (variant) {
            LiveThumbnailVariant.Simple -> LiveThumbnailSize(
                rootWidthDp = 70,
                rootHeightDp = null,
                profileAreaHeightDp = 76,
                imageSizeDp = 58,
                textWidthDp = 70
            )
            LiveThumbnailVariant.Detail -> LiveThumbnailSize(
                rootWidthDp = 266,
                rootHeightDp = 99,
                profileAreaHeightDp = null,
                imageSizeDp = 75,
                textWidthDp = 149
            )
        }
    }
}
  • Step 4: GREEN 실행

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

Expected: BUILD SUCCESSFUL

Task 4: Drawable resources 추가

Files:

  • Add if missing: app/src/main/res/drawable/bg_live_thumbnail_badge.xml

  • Add if missing: app/src/main/res/drawable/bg_live_thumbnail_badge_capsule.xml

  • Add if missing: app/src/main/res/drawable/bg_live_thumbnail_dot.xml

  • Add if missing: app/src/main/res/drawable/bg_live_thumbnail_detail.xml

  • Add if missing: app/src/main/res/drawable/bg_live_thumbnail_ring.xml

  • Read: app/src/main/res/values/colors.xml

  • Step 1: 기존 동일 drawable 존재 여부 확인

Run: rg -n "62CFFF|color_62cfff|live_thumbnail|img_live|gray_900|<stroke" app/src/main/res/drawable app/src/main/res/values

Expected: 재사용 가능한 drawable이 있으면 새 파일을 만들지 않고 계획 문서에 재사용 파일명을 기록한다. 없으면 다음 step의 drawable을 추가한다.

  • Step 2: LIVE badge drawable 추가
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/black" />
    <stroke
        android:width="2dp"
        android:color="@color/color_62cfff" />
    <corners android:radius="100dp" />
</shape>
  • Step 3: Detail LIVE badge capsule drawable 추가
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/black" />
    <corners android:radius="100dp" />
</shape>
  • Step 4: LIVE dot drawable 추가
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">
    <solid android:color="@color/color_ea3a25" />
</shape>
  • Step 5: Detail root drawable 추가
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <solid android:color="@color/gray_900" />
    <stroke
        android:width="2dp"
        android:color="@color/color_62cfff" />
    <corners android:radius="90dp" />
</shape>
  • Step 6: Simple ring drawable 추가
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">
    <solid android:color="@android:color/transparent" />
    <stroke
        android:width="2dp"
        android:color="@color/color_62cfff" />
</shape>
  • Step 7: Resource merge 확인

Run: ./gradlew :app:mergeDebugResources

Expected: BUILD SUCCESSFUL

Task 5: Simple layout 및 view 구현

Files:

  • Create: app/src/main/res/layout/view_live_thumbnail_simple.xml

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailSimpleView.kt

  • Step 1: Simple XML layout 추가

<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.livethumbnail.LiveThumbnailSimpleView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="70dp"
    android:layout_height="wrap_content"
    android:gravity="center_horizontal"
    android:orientation="vertical">

    <FrameLayout
        android:layout_width="70dp"
        android:layout_height="76dp">

        <View
            android:layout_width="70dp"
            android:layout_height="70dp"
            android:background="@drawable/bg_live_thumbnail_ring" />

        <ImageView
            android:id="@+id/iv_live_thumbnail_image"
            android:layout_width="58dp"
            android:layout_height="58dp"
            android:layout_marginStart="@dimen/spacing_6"
            android:layout_marginTop="@dimen/spacing_6"
            android:contentDescription="@null"
            android:scaleType="centerCrop"
            tools:src="@drawable/ic_launcher_background" />

        <LinearLayout
            android:layout_width="50dp"
            android:layout_height="18dp"
            android:layout_gravity="bottom|center_horizontal"
            android:background="@drawable/bg_live_thumbnail_badge"
            android:gravity="center"
            android:orientation="horizontal"
            android:paddingHorizontal="@dimen/spacing_8">

            <View
                android:layout_width="@dimen/spacing_8"
                android:layout_height="@dimen/spacing_8"
                android:background="@drawable/bg_live_thumbnail_dot" />

            <TextView
                style="@style/Typography.Caption3"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="@dimen/spacing_4"
                android:text="LIVE"
                android:textColor="@color/white"
                tools:ignore="HardcodedText,SmallSp" />
        </LinearLayout>
    </FrameLayout>

    <TextView
        android:id="@+id/tv_live_thumbnail_creator"
        style="@style/Typography.Body5"
        android:layout_width="70dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/spacing_6"
        android:ellipsize="end"
        android:gravity="center"
        android:maxLines="1"
        android:textColor="@color/white"
        tools:text="크리에이터이름" />
</kr.co.vividnext.sodalive.v2.widget.livethumbnail.LiveThumbnailSimpleView>
  • Step 2: Simple custom view 추가
package kr.co.vividnext.sodalive.v2.widget.livethumbnail

import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import kr.co.vividnext.sodalive.R

class LiveThumbnailSimpleView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

    private var image: ImageView? = null
    private var creatorText: TextView? = null
    private var currentItem: LiveThumbnailItem? = null
    private var clickListener: ((LiveThumbnailItem) -> Unit)? = null

    override fun onFinishInflate() {
        super.onFinishInflate()
        image = findViewById(R.id.iv_live_thumbnail_image)
        creatorText = findViewById(R.id.tv_live_thumbnail_creator)
        imageView().clipToOutline = true
        imageView().outlineProvider = circleOutlineProvider()
    }

    fun bind(item: LiveThumbnailItem) {
        currentItem = item
        requireNotNull(creatorText).text = item.creatorName
        isClickable = clickListener != null
        setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
    }

    fun imageView(): ImageView = requireNotNull(image)

    fun setOnLiveThumbnailClick(listener: ((LiveThumbnailItem) -> Unit)?) {
        clickListener = listener
        currentItem?.let(::bind)
    }

    private fun circleOutlineProvider() = object : ViewOutlineProvider() {
        override fun getOutline(view: View, outline: Outline) {
            outline.setOval(0, 0, view.width, view.height)
        }
    }
}
  • Step 3: Resource merge 확인

Run: ./gradlew :app:mergeDebugResources

Expected: BUILD SUCCESSFUL

Task 6: Detail layout 및 view 구현

Files:

  • Create: app/src/main/res/layout/view_live_thumbnail_detail.xml

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailDetailView.kt

  • Step 1: Detail XML layout 추가

<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.livethumbnail.LiveThumbnailDetailView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="266dp"
    android:layout_height="99dp"
    android:background="@drawable/bg_live_thumbnail_detail"
    android:clipToOutline="true">

    <ImageView
        android:id="@+id/iv_live_thumbnail_image"
        android:layout_width="75dp"
        android:layout_height="75dp"
        android:layout_marginStart="10dp"
        android:contentDescription="@null"
        android:scaleType="centerCrop"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:src="@drawable/ic_launcher_background" />

    <LinearLayout
        android:id="@+id/ll_live_thumbnail_text_container"
        android:layout_width="149dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="93dp"
        android:orientation="vertical"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="18dp"
            android:gravity="center_vertical"
            android:orientation="horizontal">

            <LinearLayout
                android:layout_width="wrap_content"
                android:layout_height="18dp"
                android:background="@drawable/bg_live_thumbnail_badge_capsule"
                android:gravity="center"
                android:orientation="horizontal"
                android:paddingHorizontal="@dimen/spacing_4">

                <View
                    android:layout_width="@dimen/spacing_8"
                    android:layout_height="@dimen/spacing_8"
                    android:background="@drawable/bg_live_thumbnail_dot" />

                <TextView
                    style="@style/Typography.Caption3"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="@dimen/spacing_4"
                    android:text="LIVE"
                    android:textColor="@color/white"
                    tools:ignore="HardcodedText,SmallSp" />
            </LinearLayout>

            <TextView
                android:id="@+id/tv_live_thumbnail_start_time"
                style="@style/Typography.Body6"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginStart="@dimen/spacing_8"
                android:layout_weight="1"
                android:ellipsize="end"
                android:maxLines="1"
                android:textColor="@color/gray_500"
                tools:text="00:00" />
        </LinearLayout>

        <TextView
            android:id="@+id/tv_live_thumbnail_title"
            style="@style/Typography.Heading4"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/spacing_4"
            android:ellipsize="end"
            android:maxLines="1"
            android:textColor="@color/white"
            tools:text="라이브 제목" />

        <TextView
            android:id="@+id/tv_live_thumbnail_creator"
            style="@style/Typography.Body6"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/spacing_4"
            android:ellipsize="end"
            android:maxLines="1"
            android:textColor="@color/gray_500"
            tools:text="크리에이터이름" />
    </LinearLayout>
</kr.co.vividnext.sodalive.v2.widget.livethumbnail.LiveThumbnailDetailView>
  • Step 2: Detail custom view 추가
package kr.co.vividnext.sodalive.v2.widget.livethumbnail

import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import kr.co.vividnext.sodalive.R

class LiveThumbnailDetailView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {

    private var image: ImageView? = null
    private var liveStartTimeText: TextView? = null
    private var titleText: TextView? = null
    private var creatorText: TextView? = null
    private var currentItem: LiveThumbnailItem? = null
    private var clickListener: ((LiveThumbnailItem) -> Unit)? = null

    override fun onFinishInflate() {
        super.onFinishInflate()
        image = findViewById(R.id.iv_live_thumbnail_image)
        liveStartTimeText = findViewById(R.id.tv_live_thumbnail_start_time)
        titleText = findViewById(R.id.tv_live_thumbnail_title)
        creatorText = findViewById(R.id.tv_live_thumbnail_creator)
        imageView().clipToOutline = true
        imageView().outlineProvider = circleOutlineProvider()
    }

    fun bind(item: LiveThumbnailItem) {
        currentItem = item
        requireNotNull(liveStartTimeText).text = item.liveStartTimeText
        requireNotNull(titleText).text = item.title
        requireNotNull(creatorText).text = item.creatorName
        isClickable = clickListener != null
        setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
    }

    fun imageView(): ImageView = requireNotNull(image)

    fun setOnLiveThumbnailClick(listener: ((LiveThumbnailItem) -> Unit)?) {
        clickListener = listener
        currentItem?.let(::bind)
    }

    private fun circleOutlineProvider() = object : ViewOutlineProvider() {
        override fun getOutline(view: View, outline: Outline) {
            outline.setOval(0, 0, view.width, view.height)
        }
    }
}
  • Step 3: Resource merge 확인

Run: ./gradlew :app:mergeDebugResources

Expected: BUILD SUCCESSFUL

Task 7: 실제 이미지 로딩 호출 예시 정리

Files:

  • No immediate file changes in this task.

  • Use this task as the binding contract for the caller screen selected in a later implementation request.

  • Step 1: 호출부 이미지 로딩 방식 확인

Run: rg -n "\.load\(|Glide\.with|iv_.*\.load|placeholder\(" app/src/main/java/kr/co/vividnext/sodalive

Expected: 대상 화면이 Coil 또는 Glide 중 어떤 방식을 사용하는지 확인한다.

  • Step 1-1: 컴포넌트 내부 이미지 로더 비고정 확인

Run: rg -n "Glide\.with|coil|\.load\(" app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail

Expected: 검색 결과가 없어야 한다. imageView()만 노출하고 실제 이미지 로딩은 호출부가 수행한다.

  • Step 2: Simple 호출부 바인딩 예시 문서화
binding.liveThumbnailSimple.bind(item)
binding.liveThumbnailSimple.imageView().load(item.imageUrl) {
    crossfade(true)
    placeholder(R.drawable.ic_place_holder)
}
binding.liveThumbnailSimple.setOnLiveThumbnailClick { liveThumbnailItem ->
    // 호출 화면의 라이브 상세 이동 로직을 연결한다.
}
  • Step 3: Detail 호출부 바인딩 예시 문서화
binding.liveThumbnailDetail.bind(item)
binding.liveThumbnailDetail.imageView().load(item.imageUrl) {
    crossfade(true)
    placeholder(R.drawable.ic_place_holder)
}
binding.liveThumbnailDetail.setOnLiveThumbnailClick { liveThumbnailItem ->
    // 호출 화면의 라이브 상세 이동 로직을 연결한다.
}

Expected: 실제 구현 시 이미지 영역은 Figma 빈 이미지가 아니라 item.imageUrl에서 로드된 이미지로 표시된다.

Task 8: 최종 검증

Files:

  • Check: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailItem.kt

  • Check: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailVariant.kt

  • Check: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailSize.kt

  • Check: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailSimpleView.kt

  • Check: app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail/LiveThumbnailDetailView.kt

  • Check: app/src/main/res/layout/view_live_thumbnail_simple.xml

  • Check: app/src/main/res/layout/view_live_thumbnail_detail.xml

  • Step 1: changed Kotlin 파일 LSP diagnostics 확인

Run tool: lsp_diagnostics on each changed Kotlin file.

Expected: 새로 추가한 Kotlin 파일에 error가 없다.

  • Step 2: 단위 테스트 실행

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

Expected: BUILD SUCCESSFUL

  • Step 3: Android resource merge 실행

Run: ./gradlew :app:mergeDebugResources

Expected: BUILD SUCCESSFUL

  • Step 4: 전체 app test 실행

Run: ./gradlew :app:testDebugUnitTest

Expected: BUILD SUCCESSFUL 또는 변경과 무관한 기존 실패만 별도 기록한다.

  • Step 4-1: UI contract 속성 확인

Run: rg -n "bg_live_thumbnail_badge_capsule|clipToOutline=\"true\"|outlineProvider|scaleType=\"centerCrop\"|maxLines=\"1\"|ellipsize=\"end\"|color_62cfff" app/src/main/res/layout app/src/main/res/drawable app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail

Expected: Detail LIVE badge는 bg_live_thumbnail_badge_capsule, 이미지 영역은 centerCrop과 circle outline clipping, 사용자 텍스트는 1줄 말줄임, cyan 색상은 color_62cfff resource 참조로 확인된다.

  • Step 4-2: Figma 시각 정합성 QA 기준 확인

Expected manual/screenshot QA:

  • Simple70dp 폭, 70dp ring, 58dp 원형 이미지, bottom centered LIVE badge, 1줄 creator name을 유지한다.

  • Detail266dp x 99dp, 75dp 원형 이미지, stroke 없는 LIVE capsule badge, start time/title/creator 1줄 말줄임을 유지한다.

  • Step 5: 계획 문서 검증 기록 누적

Append to this file:

## 검증 기록

### YYYY-MM-DD HH:mm KST
- 무엇: 라이브 썸네일 컴포넌트 구현 검증
- 왜: Figma `24:4999`, `24:5017` 기준 UI와 실제 이미지 바인딩 계약이 동작하는지 확인
- 어떻게:
  - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.livethumbnail.*"`
  - `./gradlew :app:mergeDebugResources`
  - `./gradlew :app:testDebugUnitTest`
- 결과: 실행 결과를 그대로 기록한다.

체크리스트

  • AC1: Simple variant는 70dp root width, 70dp x 76dp profile area, 58dp 원형 이미지, LIVE ring/badge를 사용한다.
  • AC2: Detail variant는 266dp x 99dp root, 75dp 원형 이미지, 149dp text column, stroke 없는 LIVE capsule badge를 사용한다.
  • AC3: Detail root와 Simple ring/badge stroke의 cyan 색상은 color_62cfff resource를 통해 참조한다.
  • AC4: 모든 사용자 표시 텍스트는 maxLines=1, ellipsize=end를 적용한다.
  • AC5: 컴포넌트 내부는 이미지 로딩 라이브러리를 고정하지 않고 imageView()를 노출해 호출부가 Coil/Glide 등 기존 정책으로 로드한다.
  • AC6: 기존 레거시 live layout과 호출 화면은 이번 widget 계약에서 직접 변경하지 않는다.
  • AC7: livethumbnail 단위 테스트, debug resource merge, 전체 debug unit test 또는 변경과 무관한 기존 실패 기록으로 검증한다.

검증 기록

2026-05-20 KST

  • 무엇: 코드 리뷰 반영 문서/리소스 정합성 검증
  • 왜: Detail LIVE badge capsule 문서 누락, 이미지 로더 비고정 검증, AC/QA 기준, cyan color resource 분리를 반영했는지 확인
  • 어떻게:
    • rg -n "Glide\.with|coil|\.load\(" app/src/main/java/kr/co/vividnext/sodalive/v2/widget/livethumbnail
    • ./gradlew :app:mergeDebugResources
    • XML LSP diagnostics 시도
  • 결과:
    • livethumbnail 내부 이미지 로더 검색: 결과 없음
    • debug resource merge: BUILD SUCCESSFUL
    • XML LSP diagnostics: 이 환경에 .xml LSP 서버가 없어 실행 불가, resource merge로 대체 확인

2026-05-20 KST

  • 무엇: 라이브 썸네일 컴포넌트 구현 검증
  • 왜: Figma 24:4999, 24:5017 기준 UI와 실제 이미지 바인딩 계약이 동작하는지 확인
  • 어떻게:
    • ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.livethumbnail.*"
    • ./gradlew :app:mergeDebugResources
    • ./gradlew :app:testDebugUnitTest
    • lsp_diagnostics on changed Kotlin files
  • 결과:
    • livethumbnail 단위 테스트: BUILD SUCCESSFUL
    • debug resource merge: BUILD SUCCESSFUL
    • 전체 debug unit test: BUILD SUCCESSFUL
    • Kotlin LSP diagnostics: 이 환경에 .kt LSP 서버가 없어 실행 불가, Gradle Kotlin compile/test로 대체 확인

2026-05-20 KST

  • 무엇: 코드 리뷰 후 비차단 개선 검증
  • 왜: review-work에서 제안된 XML typography 정합성과 size contract 테스트 보강을 반영했는지 확인
  • 어떻게:
    • ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.livethumbnail.*"
    • ./gradlew :app:mergeDebugResources
  • 결과:
    • LIVE badge 및 사용자 텍스트 TextView에 android:includeFontPadding="false" 적용
    • LiveThumbnailSizeTest에 nullable contract assertion 추가
    • livethumbnail 단위 테스트: BUILD SUCCESSFUL
    • debug resource merge: BUILD SUCCESSFUL

2026-05-20 KST

  • 무엇: 기존 디자인 토큰 적용 검증
  • 왜: 신규 디자인 토큰 생성 없이 현재 미커밋 UI 파일에서 기존 Typography/spacing/color token을 적용할 수 있는 하드코딩을 줄이기 위함
  • 어떻게:
    • app/src/main/res/values/typography.xml, dimens.xml, colors.xml 확인
    • view_live_thumbnail_simple.xml, view_live_thumbnail_detail.xml, bg_live_thumbnail_*.xml 확인
    • visual-engineering 검토로 추가 안전 치환 가능 여부 확인
  • 결과:
    • LIVE label: @style/Typography.Caption3 적용
    • Simple creator: @style/Typography.Body5 적용
    • Detail start time/creator: @style/Typography.Body6 적용
    • Detail title: @style/Typography.Heading4 적용
    • 4dp, 6dp, 8dp spacing은 기존 @dimen/spacing_4, @dimen/spacing_6, @dimen/spacing_8로 치환
    • #62CFFF는 이후 color_62cfff resource로 분리하고 drawable에서 참조하도록 보강
    • 90dp, 100dp, 컴포넌트 고유 width/height는 정확히 대응되는 기존 token이 없어 유지

2026-05-20 KST

  • 무엇: 문서 작성 범위 검증
  • 왜: 사용자가 구현이 아닌 문서 작성만 요청했으므로 코드 변경 없이 PRD와 구현 계획/TASK 문서만 준비했는지 확인
  • 어떻게: Figma 24:4999, 24:5017 design context/screenshot 확인, 기존 v2 widget 및 live layout 패턴 확인
  • 결과: docs/prd/20260520_라이브썸네일컴포넌트_prd.md, docs/plan-task/20260520_라이브썸네일컴포넌트.md 문서만 추가 대상으로 작성함