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
)
}

View File

@@ -0,0 +1,538 @@
# 캐릭터 채팅 썸네일 컴포넌트 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:5032` 기준으로 캐릭터 이미지, 총 채팅 개수, 이름, 소개, 원작 작품명을 표시하는 재사용 가능한 캐릭터 채팅 썸네일 컴포넌트를 추가한다.
**Architecture:** 캐릭터 카드 표시 데이터와 채팅 수 포맷을 순수 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.
---
## 작업 목표
- Figma `24:5032` 기준 `chat-thumbnail` 카드 UI를 구현한다.
- 이미지 영역은 `185dp x 185dp`로 고정하고 centerCrop + clipping을 적용한다.
- 왼쪽 상단 badge에 `ic_chat_message_count`와 캐릭터 총 채팅 개수를 표시한다.
- 캐릭터 총 채팅 개수가 `100` 미만이면 왼쪽 상단 채팅 수 badge를 표시하지 않는다.
- 캐릭터 이름은 1줄, 캐릭터 소개는 2줄, 원작 작품명은 1줄 말줄임으로 표시한다.
- `hasOriginal == false`일 때 원작 작품명 영역은 `View.INVISIBLE`로 처리해 카드 높이를 유지한다.
- 기존 캐릭터 목록 화면 연결은 별도 승인 전까지 하지 않는다.
## 파일 구조
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItem.kt`
- 캐릭터 채팅 썸네일 UI에 필요한 최소 데이터 계약을 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatter.kt`
- 총 채팅 개수를 Figma 예시와 같은 표시 문자열로 변환한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailView.kt`
- 텍스트 바인딩, 원작 표시 상태, 이미지 view 노출, 터치 callback을 처리한다.
- Create: `app/src/main/res/layout/view_character_chat_thumbnail.xml`
- Figma `24:5032` 기준 XML layout을 정의한다.
- Add if missing: `app/src/main/res/drawable/bg_character_chat_thumbnail.xml`
- root `gray_900` background + `14dp` radius를 정의한다.
- Add if missing: `app/src/main/res/drawable/bg_character_chat_count_badge.xml`
- 채팅 수 badge black `60%` background + `4dp` radius를 정의한다.
- Add if missing: `app/src/main/res/drawable/bg_character_chat_thumbnail_dim.xml`
- 이미지 하단 dim gradient를 정의한다.
- Add if missing: `app/src/main/res/drawable/ic_chat_message_count.xml`
- Figma 채팅 수 아이콘을 Android drawable로 추가한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItemTest.kt`
- 원작 표시 정책과 데이터 보존을 검증한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatterTest.kt`
- 채팅 수 표시 문자열을 검증한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailViewTest.kt`
- `bind`, `imageView`, `setOnCharacterClick` 동작을 Robolectric 기반 로컬 단위 테스트로 검증한다.
- 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/livethumbnail/LiveThumbnailSimpleView.kt`
- Read: `app/src/main/res/layout/item_character.xml`
- Read: `app/src/main/res/layout/item_new_character_all.xml`
- Read: `app/src/main/res/layout/item_other_character.xml`
- Read: `app/src/main/res/values/colors.xml`
- Read: `app/src/main/res/values/dimens.xml`
- Read: `app/src/main/res/values/typography.xml`
- [x] **Step 1: 관련 기존 코드 확인**
Run: `rg -n "AudioContentCardView|LiveThumbnailSimpleView|item_character|item_new_character_all|ic_chat_message_count|ellipsize=\"end\"|maxLines=\"2\"" app/src/main/java app/src/main/res/layout app/src/main/res/drawable app/src/main/res/values`
Expected: 기존 v2 custom view의 `imageView()` 노출 패턴, 캐릭터 카드 layout, 1줄/2줄 ellipsis 적용 예시, `ic_chat_message_count` 존재 여부를 확인한다.
- [x] **Step 2: Figma 세부 컨텍스트 재확인**
Run tools:
- `Figma_get_design_context(24:5032)`
- `Figma_get_screenshot(24:5032)`
Expected: root size, image size, badge 위치, typography, color, radius, spacing을 확인한다.
- [x] **Step 3: 구현 기준 token 정리**
Expected token contract:
- root width: `185dp`
- image area: `185dp x 185dp`
- root background: `gray_900`, radius `14dp`
- chat count badge: start/top `8dp`, black `60%`, radius `4dp`, padding horizontal `4dp`, padding vertical `2dp`
- chat count icon: `ic_chat_message_count`, `18dp x 18dp`
- chat count text: Pretendard Regular `14sp`, `gray_100`, line-height `1.45`
- text column: start/end `12dp`, top `176dp`, gap `6dp`
- character name: Pretendard Bold `20sp`, white, maxLines `1`
- character description: Pretendard Medium `14sp`, white, line-height `1.45`, maxLines `2`
- original title: Pretendard Medium `12sp`, `soda_300`, maxLines `1`
### Task 2: Character thumbnail data contract TDD
**Files:**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItemTest.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItem.kt`
- [x] **Step 1: RED - item display contract 테스트 추가**
```kotlin
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
)
}
```
- [x] **Step 2: RED 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItemTest"`
Expected: `Unresolved reference 'CharacterChatThumbnailItem'`로 실패한다.
- [x] **Step 3: GREEN - 최소 data contract 추가**
```kotlin
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
}
```
- [x] **Step 4: GREEN 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatThumbnailItemTest"`
Expected: `BUILD SUCCESSFUL`
### Task 3: Chat count formatter TDD
**Files:**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatterTest.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatter.kt`
- [x] **Step 1: RED - Figma 예시 기반 count format 테스트 추가**
```kotlin
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))
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))
}
}
```
- [x] **Step 2: RED 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatCountFormatterTest"`
Expected: `Unresolved reference 'CharacterChatCountFormatter'`로 실패한다.
- [x] **Step 3: GREEN - formatter 추가**
```kotlin
package kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail
import java.util.Locale
object CharacterChatCountFormatter {
fun format(count: Long): String {
val safeCount = count.coerceAtLeast(0)
if (safeCount < 10_000) return safeCount.toString()
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)
}
}
}
```
- [x] **Step 4: GREEN 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.CharacterChatCountFormatterTest"`
Expected: `BUILD SUCCESSFUL`
### Task 4: Drawable resources 추가
**Files:**
- Add if missing: `app/src/main/res/drawable/bg_character_chat_thumbnail.xml`
- Add if missing: `app/src/main/res/drawable/bg_character_chat_count_badge.xml`
- Add if missing: `app/src/main/res/drawable/bg_character_chat_thumbnail_dim.xml`
- Add if missing: `app/src/main/res/drawable/ic_chat_message_count.xml`
- [x] **Step 1: 기존 동일 리소스 존재 여부 확인**
Run: `rg -n "ic_chat_message_count|character_chat_thumbnail|4fd2f9|gray_100|gray_900" app/src/main/res/drawable app/src/main/res/values`
Expected: 재사용 가능한 drawable/color가 있으면 새 파일을 만들지 않고 계획 문서에 재사용 파일명을 기록한다. `ic_chat_message_count`가 없으면 Figma 에셋을 vector/drawable로 추가한다.
- [x] **Step 2: root background drawable 추가**
```xml
<?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="14dp" />
</shape>
```
- [x] **Step 3: chat count badge drawable 추가**
```xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#99000000" />
<corners android:radius="4dp" />
</shape>
```
- [x] **Step 4: dim gradient drawable 추가**
```xml
<?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>
```
- [x] **Step 5: Resource merge 확인**
Run: `./gradlew :app:mergeDebugResources`
Expected: `BUILD SUCCESSFUL`
### Task 5: XML layout 및 custom view 구현
**Files:**
- Create: `app/src/main/res/layout/view_character_chat_thumbnail.xml`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailView.kt`
- [x] **Step 1: XML layout 추가**
Required layout properties:
- Root custom view width `185dp`, height `wrap_content`, background `bg_character_chat_thumbnail`, clipped rounded card.
- ImageView width/height `185dp`, scaleType `centerCrop`, `contentDescription=@null`.
- Dim gradient View overlays the image area.
- Chat count badge at start/top `8dp`, background `bg_character_chat_count_badge`, padding horizontal `4dp`, padding vertical `2dp`.
- Badge icon uses `@drawable/ic_chat_message_count`, width/height `18dp`.
- Badge text uses `@style/Typography.Body6`, `@color/gray_100`, single line.
- Text container start/end margin `12dp`, top `176dp`, vertical gap `6dp`.
- Character name TextView: `@style/Typography.Heading3`, `maxLines=1`, `ellipsize=end`.
- Character description TextView: `@style/Typography.Body5`, `maxLines=2`, `ellipsize=end`.
- Original title TextView: `@style/Typography.Caption2`, `@color/soda_300`, `maxLines=1`, `ellipsize=end`.
- [x] **Step 2: custom view 추가**
Required API:
```kotlin
fun bind(item: CharacterChatThumbnailItem)
fun imageView(): ImageView
fun setOnCharacterClick(listener: ((CharacterChatThumbnailItem) -> Unit)?)
```
Required behavior:
- `bind()`는 이름, 소개, 채팅 수, 원작 작품명을 바인딩한다.
- 채팅 수는 `CharacterChatCountFormatter.format(item.chatMessageCount)` 결과를 표시한다.
- `item.shouldShowChatCountBadge == true`이면 chat count badge container는 `View.VISIBLE`이다.
- `item.shouldShowChatCountBadge == false`이면 chat count badge container는 `View.GONE`이다.
- `item.shouldShowOriginalTitle == true`이면 original title TextView는 `View.VISIBLE`이다.
- `item.shouldShowOriginalTitle == false`이면 original title TextView는 `View.INVISIBLE`이다.
- `imageView()`는 실제 이미지 로딩을 호출부가 수행할 수 있도록 ImageView를 반환한다.
- click listener가 없으면 root click listener를 제거한다.
- [x] **Step 3: Resource merge 확인**
Run: `./gradlew :app:mergeDebugResources`
Expected: `BUILD SUCCESSFUL`
### Task 6: 실제 이미지 로딩 호출 예시 정리
**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.
- [x] **Step 1: 호출부 이미지 로딩 방식 확인**
Run: `rg -n "\.load\(|Glide\.with|iv_.*\.load|placeholder\(" app/src/main/java/kr/co/vividnext/sodalive`
Expected: 대상 화면이 Coil 또는 Glide 중 어떤 방식을 사용하는지 확인한다.
- [x] **Step 2: 컴포넌트 내부 이미지 로더 비고정 확인**
Run: `rg -n "Glide\.with|coil|\.load\(" app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail`
Expected: 검색 결과가 없어야 한다. `imageView()`만 노출하고 실제 이미지 로딩은 호출부가 수행한다.
- [x] **Step 3: 호출부 바인딩 예시 문서화**
```kotlin
binding.characterChatThumbnail.bind(item)
binding.characterChatThumbnail.imageView().load(item.imageUrl) {
crossfade(true)
placeholder(R.drawable.ic_place_holder)
}
binding.characterChatThumbnail.setOnCharacterClick { characterItem ->
// 호출 화면의 캐릭터 상세 또는 채팅 진입 로직을 연결한다.
}
```
Expected: 실제 구현 시 이미지 영역은 Figma placeholder가 아니라 `item.imageUrl`에서 로드된 이미지로 표시된다.
### Task 7: 최종 검증
**Files:**
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItem.kt`
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatter.kt`
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailView.kt`
- Check: `app/src/main/res/layout/view_character_chat_thumbnail.xml`
- Check: `app/src/main/res/drawable/bg_character_chat_thumbnail.xml`
- Check: `app/src/main/res/drawable/bg_character_chat_count_badge.xml`
- Check: `app/src/main/res/drawable/bg_character_chat_thumbnail_dim.xml`
- Check: `app/src/main/res/drawable/ic_chat_message_count.xml`
- Modify: `docs/plan-task/20260520_캐릭터채팅썸네일컴포넌트.md`
- [x] **Step 1: changed Kotlin 파일 LSP diagnostics 확인**
Run tool: `lsp_diagnostics` on each changed Kotlin file.
Expected: 새로 추가한 Kotlin 파일에 error가 없다. Kotlin LSP가 환경에 없으면 Gradle compile/test 결과로 대체하고 검증 기록에 남긴다.
- [x] **Step 2: 단위 테스트 실행**
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.*"`
Expected: `BUILD SUCCESSFUL`
- [x] **Step 3: Android resource merge 실행**
Run: `./gradlew :app:mergeDebugResources`
Expected: `BUILD SUCCESSFUL`
- [x] **Step 4: UI contract 속성 확인**
Run: `rg -n "ic_chat_message_count|shouldShowChatCountBadge|View.GONE|GONE|View.INVISIBLE|INVISIBLE|maxLines=\"1\"|maxLines=\"2\"|ellipsize=\"end\"|centerCrop|CharacterChatCountFormatter" app/src/main/res/layout app/src/main/res/drawable app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail`
Expected: 채팅 수 아이콘, `100` 미만 채팅 수 badge `GONE` 처리, 원작 invisible 처리, 이름 1줄, 소개 2줄, 원작 1줄, 이미지 centerCrop, 채팅 수 formatter 적용이 확인된다.
- [x] **Step 5: 계획 문서 검증 기록 누적**
Append to this file:
```markdown
## 검증 기록
### YYYY-MM-DD HH:mm KST
- 무엇: 캐릭터 채팅 썸네일 컴포넌트 구현 검증
- 왜: Figma `24:5032` 기준 UI와 총 채팅 수/원작 표시 계약이 동작하는지 확인
- 어떻게:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.*"`
- `./gradlew :app:mergeDebugResources`
- `rg -n "ic_chat_message_count|shouldShowChatCountBadge|View.GONE|GONE|View.INVISIBLE|INVISIBLE|maxLines=\"1\"|maxLines=\"2\"|ellipsize=\"end\"|centerCrop|CharacterChatCountFormatter" app/src/main/res/layout app/src/main/res/drawable app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail`
- 결과: 실행 결과를 그대로 기록한다.
```
## 체크리스트
- [x] AC1: root card는 Figma `24:5032` 기준 `185dp` 폭, `gray_900` 배경, `14dp` radius를 사용한다.
- [x] AC2: 이미지 영역은 `185dp x 185dp`로 고정하고 `centerCrop`으로 표시한다.
- [x] AC3: `chatMessageCount >= 100`이면 왼쪽 상단 badge는 `ic_chat_message_count`와 총 채팅 개수 텍스트를 표시한다.
- [x] AC4: `chatMessageCount < 100`이면 왼쪽 상단 채팅 수 badge를 표시하지 않는다.
- [x] AC5: 채팅 수 `14000`은 Figma 예시와 같이 `1.4만`으로 표시되고, `19999`는 반올림하지 않고 `1.9만`으로 절삭 표시된다. `10만` 이상은 소수점 없이 정수 만 단위로 표시된다.
- [x] AC6: 캐릭터 이름은 `maxLines=1`, `ellipsize=end`를 적용한다.
- [x] AC7: 캐릭터 소개는 `maxLines=2`, `ellipsize=end`를 적용한다.
- [x] AC8: 원작 작품명은 `maxLines=1`, `ellipsize=end`를 적용한다.
- [x] AC9: `hasOriginal == true`이면 원작 작품명 TextView가 `View.VISIBLE`로 표시된다.
- [x] AC10: `hasOriginal == false`이면 원작 작품명 TextView가 `View.INVISIBLE`로 숨겨지고 카드 높이를 유지한다.
- [x] AC11: 컴포넌트 내부는 이미지 로딩 라이브러리를 고정하지 않고 `imageView()`를 노출해 호출부가 Coil/Glide 등 기존 정책으로 로드한다.
- [x] AC12: 기존 캐릭터 목록 화면은 사용자 추가 승인 없이 교체하지 않는다.
## 검증 기록
### 2026-05-20 KST
- 무엇: 문서 작성 범위 검증
- 왜: 사용자가 구현이 아닌 문서 작성만 요청했으므로 코드 변경 없이 PRD와 구현 계획/TASK 문서만 준비했는지 확인
- 어떻게:
- `Figma_get_design_context(24:5032)`
- `Figma_get_screenshot(24:5032)`
- `read(docs/agent-guides/workflow-docs-commits.md)`
- `read(docs/prd/sample-prd.md)`
- `read(docs/prd/20260520_라이브썸네일컴포넌트_prd.md)`
- `read(docs/plan-task/20260520_라이브썸네일컴포넌트.md)`
- `rg -n "format.*Count|count.*format|만|K|chat.*count|message.*count|DecimalFormat|NumberFormat|abbrev|abbreviat" app/src/main/java app/src/main/res docs/prd docs/plan-task`
- `rg -n "AudioContentCardView|CreatorRanking.*CardView|LiveThumbnail.*View|ellipsize=\"end\"|maxLines=\"2\"" app/src/main/java/kr/co/vividnext/sodalive/v2 app/src/main/res/layout app/src/main/res/values`
- 결과:
- PRD 문서는 `docs/prd/20260520_캐릭터채팅썸네일컴포넌트_prd.md`에 작성했다.
- 계획/TASK 문서는 `docs/plan-task/20260520_캐릭터채팅썸네일컴포넌트.md`에 작성했다.
- Figma `24:5032`에서 `chat-thumbnail`, `185dp` 이미지 영역, 좌상단 채팅 수 badge, 이름/소개/원작명 텍스트 구조를 확인했다.
- 사용자 요청에 따라 코드, 리소스, 레이아웃 구현 파일은 변경하지 않았다.
- 실제 구현과 빌드 검증은 사용자 승인 후 계획 문서 체크리스트에 따라 진행한다.
### 2026-05-20 KST
- 무엇: 채팅 수 100 미만 badge 미표시 요구사항 문서 반영
- 왜: 사용자가 총 채팅 개수가 100 미만이면 채팅 수 UI를 표시하지 않도록 문서 수정을 요청했다.
- 어떻게:
- `docs/prd/20260520_캐릭터채팅썸네일컴포넌트_prd.md`의 Goals, Display Requirements, Edge Cases, Metrics에 `chatMessageCount < 100` 미표시 조건을 추가했다.
- `docs/plan-task/20260520_캐릭터채팅썸네일컴포넌트.md`의 item contract 테스트, `CharacterChatThumbnailItem` 예시, custom view 바인딩 요구사항, 최종 검증 rg, AC에 동일 조건을 추가했다.
- 결과: 코드/리소스/레이아웃 구현 없이 문서에만 반영했다.
### 2026-05-20 KST
- 무엇: 캐릭터 채팅 썸네일 컴포넌트 구현 검증
- 왜: Figma `24:5032` 기준 UI와 총 채팅 수/원작 표시 계약이 동작하는지 확인했다.
- 어떻게:
- `lsp_diagnostics` on changed Kotlin files
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail.*"`
- `./gradlew :app:mergeDebugResources`
- `rg -n "ic_chat_message_count|shouldShowChatCountBadge|View.GONE|GONE|View.INVISIBLE|INVISIBLE|maxLines=\"1\"|maxLines=\"2\"|ellipsize=\"end\"|centerCrop|CharacterChatCountFormatter" app/src/main/res/layout app/src/main/res/drawable app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail`
- `rg -n "Glide\.with|coil|\.load\(" app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail`
- 결과:
- Kotlin LSP diagnostics: 이 환경에 `.kt` LSP 서버가 없어 실행 불가, Gradle compile/test로 대체 확인했다.
- characterchatthumbnail 단위 테스트: `BUILD SUCCESSFUL`
- debug resource merge: `BUILD SUCCESSFUL`
- UI contract 검색: `ic_chat_message_count`, `shouldShowChatCountBadge`, `View.GONE`, `View.INVISIBLE`, `centerCrop`, `maxLines`, `ellipsize`, `CharacterChatCountFormatter` 확인
- 이미지 로더 검색: 결과 없음
- 기존 캐릭터 목록 화면과 API/DTO는 변경하지 않았다.
### 2026-05-20 KST
- 무엇: 코드 리뷰 보완 반영
- 왜: `CharacterChatThumbnailSize.Figma`와 XML의 크기 source of truth 분리를 제거하고, `CharacterChatThumbnailView`의 public 동작을 직접 검증하기 위해 보완했다.
- 어떻게:
- 크기 source of truth는 `view_character_chat_thumbnail.xml`로 단일화하고 `CharacterChatThumbnailSize.kt`, `CharacterChatThumbnailSizeTest.kt`를 제거했다.
- Robolectric 기반 `CharacterChatThumbnailViewTest`를 추가해 `bind`, `imageView`, `setOnCharacterClick` 동작을 검증한다.
- 로컬 View 테스트 실행을 위해 `testOptions.unitTests.includeAndroidResources = true`, `androidx.test:core-ktx`, `robolectric`, `app/src/test/AndroidManifest.xml`을 추가했다.
- 결과:
- `CharacterChatThumbnailViewTest` 단독 실행: `BUILD SUCCESSFUL`
- `characterchatthumbnail` 패키지 단위 테스트: `BUILD SUCCESSFUL`
- debug resource merge: `BUILD SUCCESSFUL`
- debug Kotlin compile: `BUILD SUCCESSFUL`

View File

@@ -0,0 +1,148 @@
# PRD: 캐릭터 채팅 썸네일 컴포넌트
## 1. Overview
Figma `24:5032` 디자인을 기준으로 캐릭터 이미지, 총 채팅 개수, 캐릭터 이름, 캐릭터 소개, 원작 작품명을 표시하는 Android XML Views 기반 캐릭터 채팅 썸네일 컴포넌트를 문서화한다.
---
## 2. Problem
- 캐릭터 목록/추천 영역에서 캐릭터 이미지 위에 총 채팅 개수를 함께 보여야 한다.
- 캐릭터 이름과 소개가 길어질 경우 카드의 고정된 이미지/텍스트 영역을 밀어내지 않아야 한다.
- 원작 웹툰/웹소설 작품이 없는 캐릭터도 카드 높이를 유지해야 하므로 원작명 영역은 `gone`이 아닌 `invisible` 상태가 필요하다.
- Figma의 `chat-thumbnail` 형태를 기존 v2 widget 패턴에 맞는 재사용 가능한 계약으로 분리해야 한다.
---
## 3. Goals
- Figma `24:5032` 기준 캐릭터 채팅 썸네일 UI를 제공한다.
- 프로필/캐릭터 이미지는 Figma 기준 `185dp x 185dp` 고정 영역에 표시한다.
- 카드 왼쪽 상단에는 `ic_chat_message_count` 아이콘과 이 캐릭터의 총 채팅 개수를 표시한다.
- 총 채팅 개수가 `100` 미만이면 채팅 수 badge 전체를 표시하지 않는다.
- 캐릭터 이름은 최대 1줄, 초과 시 끝 말줄임으로 표시한다.
- 캐릭터 소개는 최대 2줄, 초과 시 끝 말줄임으로 표시한다.
- `hasOriginal == true`이면 최하단 원작 작품명을 표시한다.
- `hasOriginal == false`이면 원작 작품명 영역을 `View.INVISIBLE`로 숨겨 카드 높이를 유지한다.
- 실제 이미지 로딩은 컴포넌트 내부에 고정하지 않고 호출부가 기존 Coil/Glide 정책으로 처리할 수 있게 한다.
---
## 4. Non-Goals
- 이번 문서 범위에서는 코드, 리소스, 레이아웃 파일을 구현하지 않는다.
- 서버 API, DTO 필드명, 정렬, pagination, filtering 정책을 변경하지 않는다.
- Compose 컴포넌트 또는 Compose Theme를 추가하지 않는다.
- Figma에 없는 pressed animation, skeleton loading, shimmer, 신규 badge, 차단/성인/유료 상태 표시는 추가하지 않는다.
- 이미지 로딩 라이브러리 교체를 수행하지 않는다.
- 채팅방 진입 또는 캐릭터 상세 이동 정책을 컴포넌트 내부에서 결정하지 않는다.
---
## 5. Target Users
- 캐릭터 목록에서 대화할 캐릭터를 탐색하는 앱 사용자.
- 캐릭터 카드 UI를 XML layout, RecyclerView, v2 widget 패턴으로 재사용해야 하는 Android 개발자.
---
## 6. User Stories
- 사용자는 캐릭터 카드에서 해당 캐릭터의 총 채팅 개수를 즉시 확인하고 싶다.
- 사용자는 긴 캐릭터 이름이나 소개가 있어도 카드 UI가 깨지지 않는 목록을 보고 싶다.
- 사용자는 원작이 있는 캐릭터의 작품명을 카드 하단에서 확인하고 싶다.
- 개발자는 원작이 없는 캐릭터도 같은 카드 높이로 정렬되도록 바인딩하고 싶다.
---
## 7. Core Features
### Character Chat Thumbnail Widget
캐릭터 프로필 이미지와 텍스트 정보를 Figma `chat-thumbnail` 카드로 표시한다.
#### Figma References
- Character chat thumbnail: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=24-5032&m=dev
#### Figma Token Requirements
- Root card는 `185dp` 폭 기준, `gray_900` (`#202020`) 배경, radius `14dp`, overflow clipping 형태를 기준으로 한다.
- 이미지 영역은 card top에 위치한 `185dp x 185dp` 고정 영역이다.
- 이미지 영역에는 하단으로 갈수록 어두워지는 dim gradient를 올려 텍스트 가독성을 확보한다.
- 채팅 수 badge는 이미지 왼쪽 상단 `8dp, 8dp` 위치에 둔다.
- 채팅 수 badge는 black `60%` 배경, radius `4dp`, horizontal padding `4dp`, vertical padding `2dp`, icon/text gap `2dp`를 기준으로 한다.
- 채팅 수 icon은 `ic_chat_message_count`, 크기 `18dp x 18dp`를 사용한다.
- 채팅 수 text는 Pretendard Regular `14sp`, line-height `1.45`, `gray_100` (`#F2F2F2`)를 기준으로 한다.
- 텍스트 column은 좌우 `12dp`, 이미지 하단 근처 `top 176dp`, 내부 gap `6dp`를 기준으로 한다.
- 캐릭터 이름은 Pretendard Bold `20sp`, white, 최대 1줄이다.
- 캐릭터 소개는 Pretendard Medium `14sp`, line-height `1.45`, white, 최대 2줄이다.
- 원작 작품명은 Pretendard Medium `12sp`, `soda_300` (`#4FD2F9`), 최대 1줄이다.
#### Display Requirements
- `imageUrl`은 Figma placeholder가 아니라 실제 캐릭터 이미지 URL을 표시한다.
- 이미지 크기는 컴포넌트에서 고정하고, 호출부가 전달한 이미지가 영역 밖으로 늘어나지 않도록 centerCrop + clipping을 적용한다.
- 채팅 수는 이 캐릭터의 전체 누적 채팅 개수를 표시한다.
- `chatMessageCount < 100`이면 채팅 수 badge를 `View.GONE` 또는 미표시 상태로 처리한다.
- `chatMessageCount >= 100`이면 채팅 수 badge를 표시한다.
- 채팅 수 text는 Figma 예시처럼 만 단위 축약 표시를 허용하며, `10만` 미만은 소수 1자리에서 반올림하지 않고 절삭한다. 만 앞 정수부가 2자리 이상이면 소수점을 제거해 정수 만 단위로 표시한다. 예: `14000` -> `1.4만`, `19999` -> `1.9만`, `109999` -> `10만`.
- 캐릭터 이름은 `maxLines=1`, `ellipsize=end`를 적용한다.
- 캐릭터 소개는 `maxLines=2`, `ellipsize=end`를 적용한다.
- 원작 작품명이 길면 `maxLines=1`, `ellipsize=end`를 적용한다.
- `hasOriginal == true`이면 원작 작품명 TextView를 `View.VISIBLE`로 두고 `originalTitle`을 표시한다.
- `hasOriginal == false`이면 원작 작품명 TextView를 `View.INVISIBLE`로 두고 height를 유지한다.
- `hasOriginal == true`인데 `originalTitle`이 비어 있으면 빈 문자열을 표시하되 영역은 `View.VISIBLE`로 유지한다.
- 터치 동작은 컴포넌트 내부에서 목적지를 결정하지 않고 호출부 callback으로 위임한다.
#### Data Contract Requirements
- 최소 데이터 계약은 다음 정보를 포함해야 한다.
- `characterId`: 캐릭터 식별자.
- `imageUrl`: 카드에 표시할 캐릭터 이미지 URL.
- `characterName`: 캐릭터 이름.
- `characterDescription`: 캐릭터 소개.
- `chatMessageCount`: 이 캐릭터의 총 채팅 개수.
- `hasOriginal`: 원작 웹툰/웹소설 작품 존재 여부.
- `originalTitle`: 원작 작품명. `hasOriginal == true`일 때 표시한다.
- UI는 원작 타입이 웹툰인지 웹소설인지 구분하지 않고 작품명 문자열만 표시한다.
- 채팅 수 서버 필드명은 이 문서에서 확정하지 않고, 구현 단계에서 기존 응답 필드 또는 신규 매핑 필드를 확인한다.
#### Edge Cases
- `imageUrl`이 비어 있으면 호출부 이미지 로딩 정책에 따라 placeholder 또는 빈 상태를 표시한다.
- `characterName`이 비어 있으면 이름 TextView는 빈 문자열로 유지한다.
- `characterDescription`이 비어 있으면 소개 TextView는 빈 문자열로 유지한다.
- `chatMessageCount`가 0이면 `0`으로 표시한다.
- `chatMessageCount`가 0 이상 99 이하이면 채팅 수 badge를 표시하지 않는다.
- `chatMessageCount`가 음수로 전달되면 데이터 오류로 간주하고 구현 단계에서 0으로 보정하거나 항목 제외 정책을 정한다.
- 긴 한글/영문/숫자 혼합 텍스트는 지정된 줄 수에서 끝 말줄임 처리한다.
---
## 8. UX / UI Expectations
- 전체 컴포넌트는 어두운 배경 위에서 사용하는 것을 전제로 한다.
- 캐릭터 이미지는 Figma와 동일하게 카드 상단 영역을 채우고 카드 radius에 맞게 잘려야 한다.
- 채팅 수 badge는 이미지 위에서도 읽을 수 있도록 반투명 검정 배경을 유지한다.
- 원작 작품명 유무에 따라 카드 높이나 카드 간 정렬이 흔들리지 않아야 한다.
- 장식용 dim gradient는 접근성 설명을 제공하지 않는다.
- 채팅 수 아이콘은 의미 전달용이므로 badge 전체 또는 text에 접근성 설명을 제공할 수 있다.
---
## 9. Technical Constraints
- 현재 프로젝트는 Android XML Views + Kotlin custom View + ViewBinding/resource 기반이므로 XML layout과 Kotlin custom view 패턴을 우선한다.
- 신규 Kotlin 코드는 `app/src/main/java/kr/co/vividnext/sodalive/v2` 하위 패키지에 작성한다.
- 재사용 가능한 위젯은 `kr.co.vividnext.sodalive.v2.widget.characterchatthumbnail` 하위에 둔다.
- 기존 v2 widget처럼 custom view는 텍스트/상태를 바인딩하고, 실제 이미지 로딩은 `imageView()`를 통해 호출부가 처리할 수 있게 한다.
- `gray_900`, `gray_100`, `white`, `soda_300`, radius/spacing/typography 등 기존 resource를 우선 재사용한다.
- `ic_chat_message_count` 리소스가 없으면 구현 단계에서 Figma 에셋을 Android vector 또는 drawable로 추가한다.
- 기존 캐릭터 목록 layout은 참고 대상으로만 사용하고, 이번 widget 계약에서 직접 변경하지 않는다.
---
## 10. Metrics
- Figma `24:5032` 기준 root radius, 이미지 고정 영역, 채팅 수 badge, 텍스트 배치가 구현 계획에 반영된다.
- `chatMessageCount >= 100`이면 채팅 수 badge는 `ic_chat_message_count`와 총 채팅 개수 텍스트를 표시한다.
- `chatMessageCount < 100`이면 채팅 수 badge가 표시되지 않는다.
- 캐릭터 이름은 1줄 말줄임, 캐릭터 소개는 2줄 말줄임, 원작 작품명은 1줄 말줄임으로 제한된다.
- `hasOriginal == false`일 때 원작 작품명 TextView는 `View.INVISIBLE`로 처리되어 카드 높이를 유지한다.
- 실제 이미지 URL을 호출부에서 `ImageView`에 로드할 수 있다.
- 컴포넌트 내부에는 Coil/Glide 등 이미지 로딩 라이브러리 import나 `.load()` 호출이 없다.
- 문서 작성 범위에서는 코드, 리소스, 레이아웃 구현 파일을 변경하지 않는다.
---
## 11. Open Questions
- `chatMessageCount`가 현재 API 응답에 이미 존재하는지 구현 전 확인이 필요하다.
- 채팅 수 축약 포맷은 Figma 예시 `1.4만`을 기준으로 하되, `10만` 미만은 소수 1자리 절삭 정책을 적용하고 `10만` 이상은 정수 만 단위로 표시한다. 예: `19999` -> `1.9만`, `109999` -> `10만`. 기존 공용 숫자 포맷터가 있으면 구현 단계에서 재사용한다.
- 원작 작품명은 `hasOriginal` 기준으로만 표시 여부를 판단하며, `originalTitle`의 null/blank 여부만으로 표시 여부를 바꾸지 않는다.