From c32f9cdd9f2d76c058f32fdee4b24374585818c8 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 21 May 2026 11:22:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(widget):=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle | 8 + .../CharacterChatCountFormatter.kt | 19 + .../CharacterChatThumbnailItem.kt | 15 + .../CharacterChatThumbnailView.kt | 72 +++ .../bg_character_chat_count_badge.xml | 6 + .../drawable/bg_character_chat_thumbnail.xml | 6 + .../bg_character_chat_thumbnail_dim.xml | 7 + .../res/drawable/ic_chat_message_count.xml | 10 + .../layout/view_character_chat_thumbnail.xml | 100 ++++ app/src/test/AndroidManifest.xml | 8 + .../CharacterChatCountFormatterTest.kt | 42 ++ .../CharacterChatThumbnailItemTest.kt | 64 +++ .../CharacterChatThumbnailViewTest.kt | 121 ++++ .../20260520_캐릭터채팅썸네일컴포넌트.md | 538 ++++++++++++++++++ .../20260520_캐릭터채팅썸네일컴포넌트_prd.md | 148 +++++ 15 files changed, 1164 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatter.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItem.kt create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailView.kt create mode 100644 app/src/main/res/drawable/bg_character_chat_count_badge.xml create mode 100644 app/src/main/res/drawable/bg_character_chat_thumbnail.xml create mode 100644 app/src/main/res/drawable/bg_character_chat_thumbnail_dim.xml create mode 100644 app/src/main/res/drawable/ic_chat_message_count.xml create mode 100644 app/src/main/res/layout/view_character_chat_thumbnail.xml create mode 100644 app/src/test/AndroidManifest.xml create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatterTest.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItemTest.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailViewTest.kt create mode 100644 docs/plan-task/20260520_캐릭터채팅썸네일컴포넌트.md create mode 100644 docs/prd/20260520_캐릭터채팅썸네일컴포넌트_prd.md diff --git a/app/build.gradle b/app/build.gradle index 2bf5997e..019baa60 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatter.kt new file mode 100644 index 00000000..d182faf1 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatter.kt @@ -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) + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItem.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItem.kt new file mode 100644 index 00000000..fa1728ba --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItem.kt @@ -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 +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailView.kt new file mode 100644 index 00000000..e948add8 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailView.kt @@ -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) + } + } +} diff --git a/app/src/main/res/drawable/bg_character_chat_count_badge.xml b/app/src/main/res/drawable/bg_character_chat_count_badge.xml new file mode 100644 index 00000000..6db9bb5e --- /dev/null +++ b/app/src/main/res/drawable/bg_character_chat_count_badge.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_character_chat_thumbnail.xml b/app/src/main/res/drawable/bg_character_chat_thumbnail.xml new file mode 100644 index 00000000..9b7da752 --- /dev/null +++ b/app/src/main/res/drawable/bg_character_chat_thumbnail.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_character_chat_thumbnail_dim.xml b/app/src/main/res/drawable/bg_character_chat_thumbnail_dim.xml new file mode 100644 index 00000000..ca2b29fa --- /dev/null +++ b/app/src/main/res/drawable/bg_character_chat_thumbnail_dim.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/ic_chat_message_count.xml b/app/src/main/res/drawable/ic_chat_message_count.xml new file mode 100644 index 00000000..7fec1c0c --- /dev/null +++ b/app/src/main/res/drawable/ic_chat_message_count.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/view_character_chat_thumbnail.xml b/app/src/main/res/layout/view_character_chat_thumbnail.xml new file mode 100644 index 00000000..3ed5669c --- /dev/null +++ b/app/src/main/res/layout/view_character_chat_thumbnail.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/AndroidManifest.xml b/app/src/test/AndroidManifest.xml new file mode 100644 index 00000000..c6338400 --- /dev/null +++ b/app/src/test/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatterTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatterTest.kt new file mode 100644 index 00000000..2add2116 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatCountFormatterTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItemTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItemTest.kt new file mode 100644 index 00000000..052eb31b --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailItemTest.kt @@ -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 + ) +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailViewTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailViewTest.kt new file mode 100644 index 00000000..680bc850 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/characterchatthumbnail/CharacterChatThumbnailViewTest.kt @@ -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(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() + 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 + ) +} diff --git a/docs/plan-task/20260520_캐릭터채팅썸네일컴포넌트.md b/docs/plan-task/20260520_캐릭터채팅썸네일컴포넌트.md new file mode 100644 index 00000000..2fb0d3ef --- /dev/null +++ b/docs/plan-task/20260520_캐릭터채팅썸네일컴포넌트.md @@ -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 + + + + + +``` + +- [x] **Step 3: chat count badge drawable 추가** + +```xml + + + + + +``` + +- [x] **Step 4: dim gradient drawable 추가** + +```xml + + + + +``` + +- [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` diff --git a/docs/prd/20260520_캐릭터채팅썸네일컴포넌트_prd.md b/docs/prd/20260520_캐릭터채팅썸네일컴포넌트_prd.md new file mode 100644 index 00000000..ebaab09f --- /dev/null +++ b/docs/prd/20260520_캐릭터채팅썸네일컴포넌트_prd.md @@ -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 여부만으로 표시 여부를 바꾸지 않는다.