feat(widget): 라이브 썸네일 컴포넌트를 추가한다

This commit is contained in:
2026-05-20 17:55:19 +09:00
parent 960e78afac
commit c58f03be08
18 changed files with 1422 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
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
applyClickState(item)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnLiveThumbnailClick(listener: ((LiveThumbnailItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyClickState)
}
private fun applyClickState(item: LiveThumbnailItem) {
isClickable = clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun circleOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setOval(0, 0, view.width, view.height)
}
}
}

View File

@@ -0,0 +1,10 @@
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
)

View File

@@ -0,0 +1,55 @@
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
applyClickState(item)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnLiveThumbnailClick(listener: ((LiveThumbnailItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyClickState)
}
private fun applyClickState(item: LiveThumbnailItem) {
isClickable = clickListener != null
setOnClickListener(if (isClickable) View.OnClickListener { clickListener?.invoke(item) } else null)
}
private fun circleOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setOval(0, 0, view.width, view.height)
}
}
}

View File

@@ -0,0 +1,28 @@
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
)
}
}
}

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.v2.widget.livethumbnail
enum class LiveThumbnailVariant {
Simple,
Detail
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

View File

@@ -0,0 +1,9 @@
<?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>

View File

@@ -0,0 +1,6 @@
<?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>

View File

@@ -0,0 +1,9 @@
<?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>

View File

@@ -0,0 +1,5 @@
<?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>

View File

@@ -0,0 +1,8 @@
<?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>

View File

@@ -0,0 +1,100 @@
<?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:includeFontPadding="true"
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:includeFontPadding="false"
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:includeFontPadding="false"
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:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/gray_500"
tools:text="크리에이터이름" />
</LinearLayout>
</kr.co.vividnext.sodalive.v2.widget.livethumbnail.LiveThumbnailDetailView>

View File

@@ -0,0 +1,66 @@
<?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="wrap_content"
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:includeFontPadding="true"
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:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
tools:text="크리에이터이름" />
</kr.co.vividnext.sodalive.v2.widget.livethumbnail.LiveThumbnailSimpleView>

View File

@@ -87,6 +87,7 @@
<color name="color_59548f">#59548F</color>
<color name="color_ffcb14">#FFCB14</color>
<color name="color_4d6aa4">#4D6AA4</color>
<color name="color_62cfff">#62CFFF</color>
<color name="color_2d7390">#2D7390</color>
<color name="color_548f7d">#548F7D</color>
<color name="color_cc979797">#CC979797</color>

View File

@@ -0,0 +1,37 @@
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)
}
}

View File

@@ -0,0 +1,29 @@
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(null, size.rootHeightDp)
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(null, size.profileAreaHeightDp)
assertEquals(75, size.imageSizeDp)
assertEquals(149, size.textWidthDp)
}
}