feat(widget): 캐릭터 채팅 썸네일 컴포넌트를 추가한다

This commit is contained in:
2026-05-21 11:22:23 +09:00
parent c58f03be08
commit c32f9cdd9f
15 changed files with 1164 additions and 0 deletions

View File

@@ -164,6 +164,12 @@ android {
checkDependencies true
checkReleaseBuilds false
}
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
@@ -287,6 +293,8 @@ dependencies {
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:6.1.0'
testImplementation 'io.mockk:mockk:1.14.6'
testImplementation 'androidx.test:core-ktx:1.6.1'
testImplementation 'org.robolectric:robolectric:4.15.1'
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail
import java.util.Locale
import kotlin.math.floor
object CharacterChatCountFormatter {
fun format(count: Long): String {
val safeCount = count.coerceAtLeast(0)
if (safeCount < 10_000) return safeCount.toString()
if (safeCount >= 100_000) return "${safeCount / 10_000}"
val value = safeCount / 10_000.0
return if (safeCount % 10_000L == 0L) {
"${safeCount / 10_000}"
} else {
String.format(Locale.KOREAN, "%.1f만", floor(value * 10) / 10)
}
}
}

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail
data class CharacterChatThumbnailItem(
val characterId: Long,
val imageUrl: String,
val characterName: String,
val characterDescription: String,
val chatMessageCount: Long,
val hasOriginal: Boolean,
val originalTitle: String
) {
val shouldShowOriginalTitle: Boolean = hasOriginal
val shouldShowChatCountBadge: Boolean = chatMessageCount >= 100
}

View File

@@ -0,0 +1,72 @@
package kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail
import android.content.Context
import android.graphics.Outline
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import kr.co.vividnext.sodalive.R
class CharacterChatThumbnailView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private var image: ImageView? = null
private var chatCountBadge: View? = null
private var chatCountText: TextView? = null
private var characterNameText: TextView? = null
private var characterDescriptionText: TextView? = null
private var originalTitleText: TextView? = null
private var currentItem: CharacterChatThumbnailItem? = null
private var clickListener: ((CharacterChatThumbnailItem) -> Unit)? = null
override fun onFinishInflate() {
super.onFinishInflate()
image = findViewById(R.id.iv_character_chat_thumbnail_image)
chatCountBadge = findViewById(R.id.ll_character_chat_count_badge)
chatCountText = findViewById(R.id.tv_character_chat_count)
characterNameText = findViewById(R.id.tv_character_chat_name)
characterDescriptionText = findViewById(R.id.tv_character_chat_description)
originalTitleText = findViewById(R.id.tv_character_chat_original_title)
clipToOutline = true
outlineProvider = roundedCardOutlineProvider()
}
fun bind(item: CharacterChatThumbnailItem) {
currentItem = item
requireNotNull(chatCountText).text = CharacterChatCountFormatter.format(item.chatMessageCount)
requireNotNull(characterNameText).text = item.characterName
requireNotNull(characterDescriptionText).text = item.characterDescription
requireNotNull(originalTitleText).apply {
text = item.originalTitle
visibility = if (item.shouldShowOriginalTitle) View.VISIBLE else View.INVISIBLE
}
requireNotNull(chatCountBadge).visibility = if (item.shouldShowChatCountBadge) View.VISIBLE else View.GONE
applyClickState(item)
}
fun imageView(): ImageView = requireNotNull(image)
fun setOnCharacterClick(listener: ((CharacterChatThumbnailItem) -> Unit)?) {
clickListener = listener
currentItem?.let(::applyClickState)
}
private fun applyClickState(item: CharacterChatThumbnailItem) {
val listener = clickListener
setOnClickListener(if (listener == null) null else View.OnClickListener { listener(item) })
isClickable = listener != null
}
private fun roundedCardOutlineProvider() = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
val radius = resources.getDimension(R.dimen.radius_14)
outline.setRoundRect(0, 0, view.width, view.height, radius)
}
}
}

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/color_99000000" />
<corners android:radius="@dimen/radius_4" />
</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/gray_900" />
<corners android:radius="@dimen/radius_14" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:endColor="#1F1F1F"
android:startColor="#001F1F1F" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
android:fillColor="@color/gray_100"
android:pathData="M9,2.5C5.13,2.5 2,5.14 2,8.4C2,10.25 3.01,11.9 4.6,12.98V15C4.6,15.38 5.02,15.6 5.34,15.4L7.53,14.02C8,14.1 8.49,14.14 9,14.14C12.87,14.14 16,11.5 16,8.24C16,5.1 12.87,2.5 9,2.5ZM5.92,8.38C5.92,7.9 6.31,7.51 6.79,7.51C7.27,7.51 7.66,7.9 7.66,8.38C7.66,8.86 7.27,9.25 6.79,9.25C6.31,9.25 5.92,8.86 5.92,8.38ZM8.13,8.38C8.13,7.9 8.52,7.51 9,7.51C9.48,7.51 9.87,7.9 9.87,8.38C9.87,8.86 9.48,9.25 9,9.25C8.52,9.25 8.13,8.86 8.13,8.38ZM10.34,8.38C10.34,7.9 10.73,7.51 11.21,7.51C11.69,7.51 12.08,7.9 12.08,8.38C12.08,8.86 11.69,9.25 11.21,9.25C10.73,9.25 10.34,8.86 10.34,8.38Z" />
</vector>

View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="185dp"
android:layout_height="wrap_content"
android:background="@drawable/bg_character_chat_thumbnail"
android:clipToOutline="true">
<ImageView
android:id="@+id/iv_character_chat_thumbnail_image"
android:layout_width="185dp"
android:layout_height="185dp"
android:contentDescription="@null"
android:scaleType="centerCrop"
tools:src="@drawable/ic_launcher_background" />
<View
android:layout_width="185dp"
android:layout_height="185dp"
android:background="@drawable/bg_character_chat_thumbnail_dim" />
<LinearLayout
android:id="@+id/ll_character_chat_count_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/spacing_8"
android:layout_marginTop="@dimen/spacing_8"
android:background="@drawable/bg_character_chat_count_badge"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_4"
android:paddingVertical="2dp">
<ImageView
android:id="@+id/iv_character_chat_count_icon"
android:layout_width="18dp"
android:layout_height="18dp"
android:contentDescription="@null"
android:src="@drawable/ic_chat_message_count" />
<TextView
android:id="@+id/tv_character_chat_count"
style="@style/Typography.Body6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:lineSpacingMultiplier="1.45"
android:maxLines="1"
android:textColor="@color/gray_100"
tools:text="1.4만" />
</LinearLayout>
<LinearLayout
android:id="@+id/ll_character_chat_text_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/spacing_12"
android:layout_marginBottom="@dimen/spacing_12"
android:layout_marginTop="176dp"
android:orientation="vertical">
<TextView
android:id="@+id/tv_character_chat_name"
style="@style/Typography.Heading3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/white"
tools:text="캐릭터 이름" />
<TextView
android:id="@+id/tv_character_chat_description"
style="@style/Typography.Body5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_6"
android:ellipsize="end"
android:includeFontPadding="false"
android:lineSpacingMultiplier="1.45"
android:maxLines="2"
android:textColor="@color/white"
tools:text="캐릭터 소개가 들어가는 부분입니다 넘어가는 경우 ...처리" />
<TextView
android:id="@+id/tv_character_chat_original_title"
style="@style/Typography.Caption2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_6"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:textColor="@color/soda_300"
tools:text="작품 이름" />
</LinearLayout>
</kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailView>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="false"
tools:replace="android:allowBackup" />
</manifest>

View File

@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail
import org.junit.Assert.assertEquals
import org.junit.Test
class CharacterChatCountFormatterTest {
@Test
fun `zero count displays zero`() {
assertEquals("0", CharacterChatCountFormatter.format(0))
}
@Test
fun `under ten thousand displays plain number`() {
assertEquals("9999", CharacterChatCountFormatter.format(9999))
}
@Test
fun `ten thousand or more displays korean ten thousand unit`() {
assertEquals("1만", CharacterChatCountFormatter.format(10000))
assertEquals("1.4만", CharacterChatCountFormatter.format(14000))
}
@Test
fun `ten thousand unit decimal is truncated`() {
assertEquals("1.9만", CharacterChatCountFormatter.format(19999))
assertEquals("2.9만", CharacterChatCountFormatter.format(29999))
assertEquals("9.9만", CharacterChatCountFormatter.format(99999))
}
@Test
fun `one hundred thousand or more displays integer korean ten thousand unit`() {
assertEquals("10만", CharacterChatCountFormatter.format(100000))
assertEquals("10만", CharacterChatCountFormatter.format(109999))
assertEquals("99만", CharacterChatCountFormatter.format(999999))
}
@Test
fun `negative count displays zero`() {
assertEquals("0", CharacterChatCountFormatter.format(-1))
}
}

View File

@@ -0,0 +1,64 @@
package kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CharacterChatThumbnailItemTest {
@Test
fun `item keeps character display fields`() {
val item = sampleItem()
assertEquals(10L, item.characterId)
assertEquals("https://example.com/character.png", item.imageUrl)
assertEquals("캐릭터 이름", item.characterName)
assertEquals("캐릭터 소개", item.characterDescription)
assertEquals(14000L, item.chatMessageCount)
}
@Test
fun `original title is visible when item has original`() {
val item = sampleItem(hasOriginal = true, originalTitle = "작품 이름")
assertTrue(item.shouldShowOriginalTitle)
assertEquals("작품 이름", item.originalTitle)
}
@Test
fun `original title keeps space when item has no original`() {
val item = sampleItem(hasOriginal = false, originalTitle = "")
assertFalse(item.shouldShowOriginalTitle)
assertEquals("", item.originalTitle)
}
@Test
fun `chat count badge is hidden when count is less than one hundred`() {
val item = sampleItem(chatMessageCount = 99L)
assertFalse(item.shouldShowChatCountBadge)
}
@Test
fun `chat count badge is visible when count is one hundred or more`() {
val item = sampleItem(chatMessageCount = 100L)
assertTrue(item.shouldShowChatCountBadge)
}
private fun sampleItem(
hasOriginal: Boolean = true,
originalTitle: String = "작품 이름",
chatMessageCount: Long = 14000L
) = CharacterChatThumbnailItem(
characterId = 10L,
imageUrl = "https://example.com/character.png",
characterName = "캐릭터 이름",
characterDescription = "캐릭터 소개",
chatMessageCount = chatMessageCount,
hasOriginal = hasOriginal,
originalTitle = originalTitle
)
}

View File

@@ -0,0 +1,121 @@
package kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail
import android.app.Application
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.test.core.app.ApplicationProvider
import kr.co.vividnext.sodalive.R
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CharacterChatThumbnailViewTest {
@Test
fun `bind displays character fields and visible states`() {
val view = inflateView()
view.bind(sampleItem())
assertEquals("1.4만", view.chatCountText().text.toString())
assertEquals(View.VISIBLE, view.chatCountBadge().visibility)
assertEquals("캐릭터 이름", view.nameText().text.toString())
assertEquals("캐릭터 소개", view.descriptionText().text.toString())
assertEquals("작품 이름", view.originalTitleText().text.toString())
assertEquals(View.VISIBLE, view.originalTitleText().visibility)
}
@Test
fun `bind hides chat badge and keeps original title space when item requires it`() {
val view = inflateView()
view.bind(sampleItem(chatMessageCount = 99L, hasOriginal = false, originalTitle = ""))
assertEquals(View.GONE, view.chatCountBadge().visibility)
assertEquals(View.INVISIBLE, view.originalTitleText().visibility)
}
@Test
fun `imageView returns thumbnail image view`() {
val view = inflateView()
assertSame(view.findViewById<ImageView>(R.id.iv_character_chat_thumbnail_image), view.imageView())
}
@Test
fun `setOnCharacterClick invokes listener with bound item`() {
val view = inflateView()
val item = sampleItem(characterId = 20L)
var clickedItem: CharacterChatThumbnailItem? = null
view.bind(item)
view.setOnCharacterClick { clickedItem = it }
view.performClick()
assertSame(item, clickedItem)
}
@Test
fun `setOnCharacterClick removes root click listener when listener is null`() {
val view = inflateView()
var clickCount = 0
view.bind(sampleItem())
view.setOnCharacterClick { clickCount += 1 }
view.setOnCharacterClick(null)
assertFalse(view.isClickable)
assertFalse(view.hasOnClickListeners())
view.performClick()
assertEquals(0, clickCount)
}
private fun inflateView(): CharacterChatThumbnailView {
val context = ApplicationProvider.getApplicationContext<Context>()
return LayoutInflater.from(context).inflate(
R.layout.view_character_chat_thumbnail,
null,
false
) as CharacterChatThumbnailView
}
private fun CharacterChatThumbnailView.chatCountBadge(): View =
findViewById(R.id.ll_character_chat_count_badge)
private fun CharacterChatThumbnailView.chatCountText(): TextView =
findViewById(R.id.tv_character_chat_count)
private fun CharacterChatThumbnailView.nameText(): TextView =
findViewById(R.id.tv_character_chat_name)
private fun CharacterChatThumbnailView.descriptionText(): TextView =
findViewById(R.id.tv_character_chat_description)
private fun CharacterChatThumbnailView.originalTitleText(): TextView =
findViewById(R.id.tv_character_chat_original_title)
private fun sampleItem(
characterId: Long = 10L,
chatMessageCount: Long = 14000L,
hasOriginal: Boolean = true,
originalTitle: String = "작품 이름"
) = CharacterChatThumbnailItem(
characterId = characterId,
imageUrl = "https://example.com/character.png",
characterName = "캐릭터 이름",
characterDescription = "캐릭터 소개",
chatMessageCount = chatMessageCount,
hasOriginal = hasOriginal,
originalTitle = originalTitle
)
}