feat(chat-ui): 채팅 쿼터 안내 액션 모델을 정리한다

This commit is contained in:
2026-04-30 12:47:36 +09:00
parent fe5af96ff7
commit 17fc70d9ee
2 changed files with 35 additions and 59 deletions

View File

@@ -7,7 +7,6 @@ package kr.co.vividnext.sodalive.chat.talk.room
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.Typeface import android.graphics.Typeface
import android.os.Bundle
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.TextUtils import android.text.TextUtils
@@ -37,10 +36,16 @@ sealed class ChatListItem {
data class UserMessage(val data: ChatMessage) : ChatListItem() data class UserMessage(val data: ChatMessage) : ChatListItem()
data class AiMessage(val data: ChatMessage, val displayName: String? = null) : ChatListItem() data class AiMessage(val data: ChatMessage, val displayName: String? = null) : ChatListItem()
data class Notice(val text: String) : ChatListItem() data class Notice(val text: String) : ChatListItem()
data class QuotaNotice(val timeText: String? = null) : ChatListItem() object QuotaNotice : ChatListItem()
object TypingIndicator : ChatListItem() object TypingIndicator : ChatListItem()
} }
enum class ChatQuotaNoticeAction {
REWARDED_AD,
PURCHASE_10_CAN,
PURCHASE_20_CAN
}
class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// 타이핑 인디케이터 표시용 정보(캐릭터 이름/프로필) // 타이핑 인디케이터 표시용 정보(캐릭터 이름/프로필)
@@ -65,6 +70,9 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
fun onPurchaseMessage(message: ChatMessage) fun onPurchaseMessage(message: ChatMessage)
fun onOpenPurchasedImage(message: ChatMessage) fun onOpenPurchasedImage(message: ChatMessage)
fun onPurchaseQuota() fun onPurchaseQuota()
fun onQuotaNoticeAction(action: ChatQuotaNoticeAction) {
onPurchaseQuota()
}
} }
private var callback: Callback? = null private var callback: Callback? = null
@@ -79,7 +87,6 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
const val VIEW_TYPE_NOTICE = 3 const val VIEW_TYPE_NOTICE = 3
const val VIEW_TYPE_TYPING_INDICATOR = 4 const val VIEW_TYPE_TYPING_INDICATOR = 4
const val VIEW_TYPE_QUOTA_NOTICE = 5 const val VIEW_TYPE_QUOTA_NOTICE = 5
private const val PAYLOAD_KEY_QUOTA_TIME = "payload_quota_time"
/** /**
* [list]와 [position]을 기준으로 그룹화 여부와 해당 아이템이 그룹의 마지막인지 계산한다. * [list]와 [position]을 기준으로 그룹화 여부와 해당 아이템이 그룹의 마지막인지 계산한다.
@@ -155,16 +162,22 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
return when (item) { return when (item) {
is ChatListItem.UserMessage -> { is ChatListItem.UserMessage -> {
val data = item.data val data = item.data
if (data.messageId != 0L) data.messageId if (data.messageId != 0L) {
else (data.localId?.hashCode()?.toLong() data.messageId
?: ("user:" + data.createdAt + data.message.hashCode()).hashCode().toLong()) } else {
data.localId?.hashCode()?.toLong()
?: ("user:" + data.createdAt + data.message.hashCode()).hashCode().toLong()
}
} }
is ChatListItem.AiMessage -> { is ChatListItem.AiMessage -> {
val data = item.data val data = item.data
if (data.messageId != 0L) data.messageId if (data.messageId != 0L) {
else (data.localId?.hashCode()?.toLong() data.messageId
?: ("ai:" + data.createdAt + data.message.hashCode()).hashCode().toLong()) } else {
data.localId?.hashCode()?.toLong()
?: ("ai:" + data.createdAt + data.message.hashCode()).hashCode().toLong()
}
} }
is ChatListItem.Notice -> ("notice:" + item.text).hashCode().toLong() is ChatListItem.Notice -> ("notice:" + item.text).hashCode().toLong()
@@ -241,18 +254,6 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
val n = newItems[newItemPosition] val n = newItems[newItemPosition]
return o == n return o == n
} }
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
val o = old[oldItemPosition]
val n = newItems[newItemPosition]
// QuotaNotice의 timeText만 변경된 경우 부분 갱신 payload 반환
if (o is ChatListItem.QuotaNotice && n is ChatListItem.QuotaNotice) {
if (o.timeText != n.timeText) {
return Bundle().apply { putString(PAYLOAD_KEY_QUOTA_TIME, n.timeText) }
}
}
return null
}
}) })
items.clear() items.clear()
items.addAll(newItems) items.addAll(newItems)
@@ -405,31 +406,13 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
is QuotaNoticeViewHolder -> { is QuotaNoticeViewHolder -> {
val item = currItem as ChatListItem.QuotaNotice holder.bind { action ->
holder.bind(item.timeText) { callback?.onQuotaNoticeAction(action)
callback?.onPurchaseQuota()
} }
} }
} }
} }
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.isNotEmpty()) {
if (holder is QuotaNoticeViewHolder) {
val bundle = payloads.find { it is Bundle } as? Bundle
if (bundle?.containsKey(PAYLOAD_KEY_QUOTA_TIME) == true) {
holder.updateTimeText(bundle.getString(PAYLOAD_KEY_QUOTA_TIME))
return
}
}
}
super.onBindViewHolder(holder, position, payloads)
}
// region ViewHolders // region ViewHolders
/** 사용자 메시지 뷰홀더: 시간 포맷팅, 상태(투명도) 표시 */ /** 사용자 메시지 뷰홀더: 시간 포맷팅, 상태(투명도) 표시 */
@@ -672,25 +655,16 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// endregion // endregion
/** 쿼터 안내 메시지 뷰홀더: 제목/남은시간 + 구매 버튼 */ /** 쿼터 안내 메시지 뷰홀더: 광고 보기 + 캔 구매 버튼 */
class QuotaNoticeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { class QuotaNoticeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvTime: TextView = itemView.findViewById(R.id.tv_time) private val btnRewardedAd: View = itemView.findViewById(R.id.ll_rewarded_ad)
private val btnPurchase: View = itemView.findViewById(R.id.ll_purchase) private val btnPurchase10Can: View = itemView.findViewById(R.id.ll_purchase_10_can)
private val btnPurchase20Can: View = itemView.findViewById(R.id.ll_purchase_20_can)
fun bind(timeText: String?, onPurchase: () -> Unit) { fun bind(onAction: (ChatQuotaNoticeAction) -> Unit) {
updateTimeText(timeText) btnRewardedAd.setOnClickListener { onAction(ChatQuotaNoticeAction.REWARDED_AD) }
btnPurchase.setOnClickListener { onPurchase() } btnPurchase10Can.setOnClickListener { onAction(ChatQuotaNoticeAction.PURCHASE_10_CAN) }
} btnPurchase20Can.setOnClickListener { onAction(ChatQuotaNoticeAction.PURCHASE_20_CAN) }
fun updateTimeText(timeText: String?) {
if (timeText.isNullOrBlank()) {
if (tvTime.visibility != View.GONE) tvTime.visibility = View.GONE
} else {
if (tvTime.visibility != View.VISIBLE) tvTime.visibility = View.VISIBLE
if (tvTime.text?.toString() != timeText) {
tvTime.text = timeText
}
}
} }
} }

View File

@@ -40,13 +40,15 @@ class ChatMessageAdapterTest {
ChatListItem.UserMessage(ChatMessage(1, "hi", "", mine = true, createdAt = 1L)), ChatListItem.UserMessage(ChatMessage(1, "hi", "", mine = true, createdAt = 1L)),
ChatListItem.AiMessage(ChatMessage(2, "hello", "", mine = false, createdAt = 2L)), ChatListItem.AiMessage(ChatMessage(2, "hello", "", mine = false, createdAt = 2L)),
ChatListItem.Notice("notice"), ChatListItem.Notice("notice"),
ChatListItem.QuotaNotice,
ChatListItem.TypingIndicator ChatListItem.TypingIndicator
) )
adapter.setItemsForTest(list) adapter.setItemsForTest(list)
assertEquals(ChatMessageAdapter.VIEW_TYPE_USER_MESSAGE, adapter.getItemViewType(0)) assertEquals(ChatMessageAdapter.VIEW_TYPE_USER_MESSAGE, adapter.getItemViewType(0))
assertEquals(ChatMessageAdapter.VIEW_TYPE_AI_MESSAGE, adapter.getItemViewType(1)) assertEquals(ChatMessageAdapter.VIEW_TYPE_AI_MESSAGE, adapter.getItemViewType(1))
assertEquals(ChatMessageAdapter.VIEW_TYPE_NOTICE, adapter.getItemViewType(2)) assertEquals(ChatMessageAdapter.VIEW_TYPE_NOTICE, adapter.getItemViewType(2))
assertEquals(ChatMessageAdapter.VIEW_TYPE_TYPING_INDICATOR, adapter.getItemViewType(3)) assertEquals(ChatMessageAdapter.VIEW_TYPE_QUOTA_NOTICE, adapter.getItemViewType(3))
assertEquals(ChatMessageAdapter.VIEW_TYPE_TYPING_INDICATOR, adapter.getItemViewType(4))
} }
@Test @Test