feat(widget): 캐릭터 채팅 썸네일 컴포넌트를 추가한다
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
10
app/src/main/res/drawable/ic_chat_message_count.xml
Normal file
10
app/src/main/res/drawable/ic_chat_message_count.xml
Normal 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>
|
||||
100
app/src/main/res/layout/view_character_chat_thumbnail.xml
Normal file
100
app/src/main/res/layout/view_character_chat_thumbnail.xml
Normal 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>
|
||||
8
app/src/test/AndroidManifest.xml
Normal file
8
app/src/test/AndroidManifest.xml
Normal 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>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user