feat(chat-quota): 쿼터 연동 및 카운트 다운 / 쿼터 구매 UX 개선(+5초 표시 보정)
- TalkApi: /api/chat/quota/me, /api/chat/quota/purchase 엔드포인트 추가 - Repository: getChatQuotaStatus(), purchaseChatQuota() 추가, sendMessage 응답 타입을 SendChatMessageResponse로 전환 - Model: ChatQuotaStatusResponse/ChatQuotaPurchaseRequest 추가, SendChatMessageResponse/ChatRoomEnterResponse 기본값 추가 - UI(Adapter): QuotaNotice 뷰타입/레이아웃 추가, 안정 ID/부분 갱신(payload) 적용, Change 애니메이션 비활성화로 깜빡임 최소화 - UI(Activity): 쿼터 0 시 입력창 숨김 + 안내 노출, 00:00:00 도달 시 /quota/me 조회 - 카운트다운 계산: epoch 기반 남은 시간 계산 + 표시용 +5초(DISPLAY_FUDGE_MS) 가산 - 구매 성공 시 로컬 30캔 차감 및 헤더 배지 즉시 갱신
This commit is contained in:
		@@ -1,12 +1,16 @@
 | 
				
			|||||||
package kr.co.vividnext.sodalive.chat.talk
 | 
					package kr.co.vividnext.sodalive.chat.talk
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import io.reactivex.rxjava3.core.Single
 | 
					import io.reactivex.rxjava3.core.Single
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomRequest
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.ChatMessagePurchaseRequest
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomResponse
 | 
					 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.room.ChatMessagesResponse
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.ChatMessagesResponse
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomEnterResponse
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomEnterResponse
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomRequest
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.CreateChatRoomResponse
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.SendChatMessageResponse
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.room.SendMessageRequest
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.SendMessageRequest
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.room.ServerChatMessage
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.ServerChatMessage
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatQuotaPurchaseRequest
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatQuotaStatusResponse
 | 
				
			||||||
import kr.co.vividnext.sodalive.common.ApiResponse
 | 
					import kr.co.vividnext.sodalive.common.ApiResponse
 | 
				
			||||||
import retrofit2.http.Body
 | 
					import retrofit2.http.Body
 | 
				
			||||||
import retrofit2.http.GET
 | 
					import retrofit2.http.GET
 | 
				
			||||||
@@ -14,7 +18,6 @@ import retrofit2.http.Header
 | 
				
			|||||||
import retrofit2.http.POST
 | 
					import retrofit2.http.POST
 | 
				
			||||||
import retrofit2.http.Path
 | 
					import retrofit2.http.Path
 | 
				
			||||||
import retrofit2.http.Query
 | 
					import retrofit2.http.Query
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.room.ChatMessagePurchaseRequest
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface TalkApi {
 | 
					interface TalkApi {
 | 
				
			||||||
    @GET("/api/chat/room/list")
 | 
					    @GET("/api/chat/room/list")
 | 
				
			||||||
@@ -48,7 +51,7 @@ interface TalkApi {
 | 
				
			|||||||
        @Header("Authorization") authHeader: String,
 | 
					        @Header("Authorization") authHeader: String,
 | 
				
			||||||
        @Path("roomId") roomId: Long,
 | 
					        @Path("roomId") roomId: Long,
 | 
				
			||||||
        @Body request: SendMessageRequest
 | 
					        @Body request: SendMessageRequest
 | 
				
			||||||
    ): Single<ApiResponse<List<ServerChatMessage>>>
 | 
					    ): Single<ApiResponse<SendChatMessageResponse>>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // 점진적 메시지 로딩 API
 | 
					    // 점진적 메시지 로딩 API
 | 
				
			||||||
    @GET("/api/chat/room/{roomId}/messages")
 | 
					    @GET("/api/chat/room/{roomId}/messages")
 | 
				
			||||||
@@ -67,4 +70,17 @@ interface TalkApi {
 | 
				
			|||||||
        @Path("messageId") messageId: Long,
 | 
					        @Path("messageId") messageId: Long,
 | 
				
			||||||
        @Body request: ChatMessagePurchaseRequest
 | 
					        @Body request: ChatMessagePurchaseRequest
 | 
				
			||||||
    ): Single<ApiResponse<ServerChatMessage>>
 | 
					    ): Single<ApiResponse<ServerChatMessage>>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 채팅 쿼터 상태 조회
 | 
				
			||||||
 | 
					    @GET("/api/chat/quota/me")
 | 
				
			||||||
 | 
					    fun getChatQuotaStatus(
 | 
				
			||||||
 | 
					        @Header("Authorization") authHeader: String
 | 
				
			||||||
 | 
					    ): Single<ApiResponse<ChatQuotaStatusResponse>>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 채팅 쿼터 구매
 | 
				
			||||||
 | 
					    @POST("/api/chat/quota/purchase")
 | 
				
			||||||
 | 
					    fun purchaseChatQuota(
 | 
				
			||||||
 | 
					        @Header("Authorization") authHeader: String,
 | 
				
			||||||
 | 
					        @Body request: ChatQuotaPurchaseRequest
 | 
				
			||||||
 | 
					    ): Single<ApiResponse<ChatQuotaStatusResponse>>
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,6 +6,7 @@
 | 
				
			|||||||
package kr.co.vividnext.sodalive.chat.talk.room
 | 
					package kr.co.vividnext.sodalive.chat.talk.room
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import android.annotation.SuppressLint
 | 
					import android.annotation.SuppressLint
 | 
				
			||||||
 | 
					import android.os.Bundle
 | 
				
			||||||
import android.text.TextUtils
 | 
					import android.text.TextUtils
 | 
				
			||||||
import android.view.LayoutInflater
 | 
					import android.view.LayoutInflater
 | 
				
			||||||
import android.view.View
 | 
					import android.view.View
 | 
				
			||||||
@@ -16,8 +17,8 @@ import android.widget.TextView
 | 
				
			|||||||
import androidx.annotation.LayoutRes
 | 
					import androidx.annotation.LayoutRes
 | 
				
			||||||
import androidx.annotation.VisibleForTesting
 | 
					import androidx.annotation.VisibleForTesting
 | 
				
			||||||
import androidx.core.view.isVisible
 | 
					import androidx.core.view.isVisible
 | 
				
			||||||
import androidx.recyclerview.widget.RecyclerView
 | 
					 | 
				
			||||||
import androidx.recyclerview.widget.DiffUtil
 | 
					import androidx.recyclerview.widget.DiffUtil
 | 
				
			||||||
 | 
					import androidx.recyclerview.widget.RecyclerView
 | 
				
			||||||
import kr.co.vividnext.sodalive.R
 | 
					import kr.co.vividnext.sodalive.R
 | 
				
			||||||
import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding
 | 
					import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding
 | 
				
			||||||
import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding
 | 
					import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding
 | 
				
			||||||
@@ -30,6 +31,7 @@ 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 TypingIndicator : ChatListItem()
 | 
					    object TypingIndicator : ChatListItem()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -56,6 +58,7 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
        fun onRetrySend(localId: String)
 | 
					        fun onRetrySend(localId: String)
 | 
				
			||||||
        fun onPurchaseMessage(message: ChatMessage)
 | 
					        fun onPurchaseMessage(message: ChatMessage)
 | 
				
			||||||
        fun onOpenPurchasedImage(message: ChatMessage)
 | 
					        fun onOpenPurchasedImage(message: ChatMessage)
 | 
				
			||||||
 | 
					        fun onPurchaseQuota()
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private var callback: Callback? = null
 | 
					    private var callback: Callback? = null
 | 
				
			||||||
@@ -69,13 +72,18 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
        const val VIEW_TYPE_AI_MESSAGE = 2
 | 
					        const val VIEW_TYPE_AI_MESSAGE = 2
 | 
				
			||||||
        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
 | 
				
			||||||
 | 
					        private const val PAYLOAD_KEY_QUOTA_TIME = "payload_quota_time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        /**
 | 
					        /**
 | 
				
			||||||
         * [list]와 [position]을 기준으로 그룹화 여부와 해당 아이템이 그룹의 마지막인지 계산한다.
 | 
					         * [list]와 [position]을 기준으로 그룹화 여부와 해당 아이템이 그룹의 마지막인지 계산한다.
 | 
				
			||||||
         * 테스트를 위해 노출된 유틸 함수이며, onBindViewHolder의 로직과 동일한 판정을 수행한다.
 | 
					         * 테스트를 위해 노출된 유틸 함수이며, onBindViewHolder의 로직과 동일한 판정을 수행한다.
 | 
				
			||||||
         */
 | 
					         */
 | 
				
			||||||
        @VisibleForTesting
 | 
					        @VisibleForTesting
 | 
				
			||||||
        internal fun computeGroupingFlags(list: List<ChatListItem>, position: Int): Pair<Boolean, Boolean> {
 | 
					        internal fun computeGroupingFlags(
 | 
				
			||||||
 | 
					            list: List<ChatListItem>,
 | 
				
			||||||
 | 
					            position: Int
 | 
				
			||||||
 | 
					        ): Pair<Boolean, Boolean> {
 | 
				
			||||||
            fun isSameSender(prev: ChatListItem?, curr: ChatListItem?): Boolean {
 | 
					            fun isSameSender(prev: ChatListItem?, curr: ChatListItem?): Boolean {
 | 
				
			||||||
                if (prev == null || curr == null) return false
 | 
					                if (prev == null || curr == null) return false
 | 
				
			||||||
                val p = when (prev) {
 | 
					                val p = when (prev) {
 | 
				
			||||||
@@ -90,6 +98,7 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
                }
 | 
					                }
 | 
				
			||||||
                return p == c
 | 
					                return p == c
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            val curr = list.getOrNull(position)
 | 
					            val curr = list.getOrNull(position)
 | 
				
			||||||
            val prev = if (position > 0) list.getOrNull(position - 1) else null
 | 
					            val prev = if (position > 0) list.getOrNull(position - 1) else null
 | 
				
			||||||
            val next = if (position < list.lastIndex) list.getOrNull(position + 1) else null
 | 
					            val next = if (position < list.lastIndex) list.getOrNull(position + 1) else null
 | 
				
			||||||
@@ -106,14 +115,19 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
            is ChatListItem.UserMessage -> {
 | 
					            is ChatListItem.UserMessage -> {
 | 
				
			||||||
                val data = item.data
 | 
					                val data = item.data
 | 
				
			||||||
                if (data.messageId != 0L) data.messageId
 | 
					                if (data.messageId != 0L) data.messageId
 | 
				
			||||||
                else (data.localId?.hashCode()?.toLong() ?: ("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) data.messageId
 | 
				
			||||||
                else (data.localId?.hashCode()?.toLong() ?: ("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()
 | 
				
			||||||
 | 
					            is ChatListItem.QuotaNotice -> ("quota_notice").hashCode().toLong()
 | 
				
			||||||
            is ChatListItem.TypingIndicator -> Long.MIN_VALUE // 고정 ID
 | 
					            is ChatListItem.TypingIndicator -> Long.MIN_VALUE // 고정 ID
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -180,11 +194,24 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
                // 안정 ID 기준 비교와 동일한 로직 적용
 | 
					                // 안정 ID 기준 비교와 동일한 로직 적용
 | 
				
			||||||
                return getStableIdFor(o) == getStableIdFor(n)
 | 
					                return getStableIdFor(o) == getStableIdFor(n)
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
 | 
					            override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
 | 
				
			||||||
                val o = old[oldItemPosition]
 | 
					                val o = old[oldItemPosition]
 | 
				
			||||||
                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)
 | 
				
			||||||
@@ -220,6 +247,7 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
            is ChatListItem.AiMessage -> item.data.mine
 | 
					            is ChatListItem.AiMessage -> item.data.mine
 | 
				
			||||||
            else -> null
 | 
					            else -> null
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        val p = mineOf(prev)
 | 
					        val p = mineOf(prev)
 | 
				
			||||||
        val c = mineOf(curr)
 | 
					        val c = mineOf(curr)
 | 
				
			||||||
        return (p != null && c != null && p == c)
 | 
					        return (p != null && c != null && p == c)
 | 
				
			||||||
@@ -237,6 +265,7 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
            is ChatListItem.AiMessage -> item.data.mine
 | 
					            is ChatListItem.AiMessage -> item.data.mine
 | 
				
			||||||
            else -> null
 | 
					            else -> null
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        val c = mineOf(curr)
 | 
					        val c = mineOf(curr)
 | 
				
			||||||
        val n = mineOf(next)
 | 
					        val n = mineOf(next)
 | 
				
			||||||
        return (c != null && n != null && c == n)
 | 
					        return (c != null && n != null && c == n)
 | 
				
			||||||
@@ -248,6 +277,7 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
            is ChatListItem.AiMessage -> VIEW_TYPE_AI_MESSAGE
 | 
					            is ChatListItem.AiMessage -> VIEW_TYPE_AI_MESSAGE
 | 
				
			||||||
            is ChatListItem.Notice -> VIEW_TYPE_NOTICE
 | 
					            is ChatListItem.Notice -> VIEW_TYPE_NOTICE
 | 
				
			||||||
            is ChatListItem.TypingIndicator -> VIEW_TYPE_TYPING_INDICATOR
 | 
					            is ChatListItem.TypingIndicator -> VIEW_TYPE_TYPING_INDICATOR
 | 
				
			||||||
 | 
					            is ChatListItem.QuotaNotice -> VIEW_TYPE_QUOTA_NOTICE
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -286,6 +316,16 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
                NoticeMessageViewHolder(inflateAndroidSimpleItem(parent))
 | 
					                NoticeMessageViewHolder(inflateAndroidSimpleItem(parent))
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            VIEW_TYPE_QUOTA_NOTICE -> {
 | 
				
			||||||
 | 
					                QuotaNoticeViewHolder(
 | 
				
			||||||
 | 
					                    LayoutInflater.from(parent.context).inflate(
 | 
				
			||||||
 | 
					                        R.layout.item_chat_quota_notice,
 | 
				
			||||||
 | 
					                        parent,
 | 
				
			||||||
 | 
					                        false
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            else -> throw IllegalArgumentException("Unknown viewType: $viewType")
 | 
					            else -> throw IllegalArgumentException("Unknown viewType: $viewType")
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -340,8 +380,32 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
            is TypingIndicatorViewHolder -> {
 | 
					            is TypingIndicatorViewHolder -> {
 | 
				
			||||||
                holder.bind(typingName, typingProfileUrl)
 | 
					                holder.bind(typingName, typingProfileUrl)
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            is QuotaNoticeViewHolder -> {
 | 
				
			||||||
 | 
					                val item = currItem as ChatListItem.QuotaNotice
 | 
				
			||||||
 | 
					                holder.bind(item.timeText) {
 | 
				
			||||||
 | 
					                    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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -352,7 +416,8 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
        fun bind(data: ChatMessage, showTime: Boolean, isGrouped: Boolean) {
 | 
					        fun bind(data: ChatMessage, showTime: Boolean, isGrouped: Boolean) {
 | 
				
			||||||
            binding.tvMessage.text = data.message
 | 
					            binding.tvMessage.text = data.message
 | 
				
			||||||
            // 화면 너비의 65%를 최대 폭으로 적용
 | 
					            // 화면 너비의 65%를 최대 폭으로 적용
 | 
				
			||||||
            binding.tvMessage.maxWidth = (itemView.resources.displayMetrics.widthPixels * 0.65f).toInt()
 | 
					            binding.tvMessage.maxWidth =
 | 
				
			||||||
 | 
					                (itemView.resources.displayMetrics.widthPixels * 0.65f).toInt()
 | 
				
			||||||
            binding.tvTime.text = formatMessageTime(data.createdAt)
 | 
					            binding.tvTime.text = formatMessageTime(data.createdAt)
 | 
				
			||||||
            binding.tvTime.visibility = if (showTime) View.VISIBLE else View.GONE
 | 
					            binding.tvTime.visibility = if (showTime) View.VISIBLE else View.GONE
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -478,7 +543,8 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
                binding.imageContainer.visibility = View.GONE
 | 
					                binding.imageContainer.visibility = View.GONE
 | 
				
			||||||
                binding.messageContainer.visibility = View.VISIBLE
 | 
					                binding.messageContainer.visibility = View.VISIBLE
 | 
				
			||||||
                binding.tvMessage.text = data.message
 | 
					                binding.tvMessage.text = data.message
 | 
				
			||||||
                binding.tvMessage.maxWidth = (itemView.resources.displayMetrics.widthPixels * 0.90f).toInt()
 | 
					                binding.tvMessage.maxWidth =
 | 
				
			||||||
 | 
					                    (itemView.resources.displayMetrics.widthPixels * 0.90f).toInt()
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // 그룹 내부 간격 최소화 (상단 패딩 축소)
 | 
					            // 그룹 내부 간격 최소화 (상단 패딩 축소)
 | 
				
			||||||
@@ -575,6 +641,28 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    // endregion
 | 
					    // endregion
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /** 쿼터 안내 메시지 뷰홀더: 제목/남은시간 + 구매 버튼 */
 | 
				
			||||||
 | 
					    class QuotaNoticeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
 | 
				
			||||||
 | 
					        private val tvTime: TextView = itemView.findViewById(R.id.tv_time)
 | 
				
			||||||
 | 
					        private val btnPurchase: View = itemView.findViewById(R.id.ll_purchase)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        fun bind(timeText: String?, onPurchase: () -> Unit) {
 | 
				
			||||||
 | 
					            updateTimeText(timeText)
 | 
				
			||||||
 | 
					            btnPurchase.setOnClickListener { onPurchase() }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        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
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // region Util
 | 
					    // region Util
 | 
				
			||||||
    private fun inflateAndroidSimpleItem(
 | 
					    private fun inflateAndroidSimpleItem(
 | 
				
			||||||
        parent: ViewGroup,
 | 
					        parent: ViewGroup,
 | 
				
			||||||
@@ -617,6 +705,7 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
				
			|||||||
                // 클릭 리스너 제거로 누수 및 중복 클릭 방지
 | 
					                // 클릭 리스너 제거로 누수 및 중복 클릭 방지
 | 
				
			||||||
                holder.itemView.findViewById<View?>(R.id.iv_retry)?.setOnClickListener(null)
 | 
					                holder.itemView.findViewById<View?>(R.id.iv_retry)?.setOnClickListener(null)
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            is AiMessageViewHolder -> {
 | 
					            is AiMessageViewHolder -> {
 | 
				
			||||||
                // 이미지 태그/리소스 정리로 잘못된 재활용 방지
 | 
					                // 이미지 태그/리소스 정리로 잘못된 재활용 방지
 | 
				
			||||||
                holder.itemView.findViewById<View?>(R.id.iv_profile)?.let { v ->
 | 
					                holder.itemView.findViewById<View?>(R.id.iv_profile)?.let { v ->
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,6 +12,8 @@ import io.reactivex.rxjava3.core.Single
 | 
				
			|||||||
import io.reactivex.rxjava3.schedulers.Schedulers
 | 
					import io.reactivex.rxjava3.schedulers.Schedulers
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.TalkApi
 | 
					import kr.co.vividnext.sodalive.chat.talk.TalkApi
 | 
				
			||||||
import kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageDao
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.db.ChatMessageDao
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatQuotaPurchaseRequest
 | 
				
			||||||
 | 
					import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatQuotaStatusResponse
 | 
				
			||||||
import kr.co.vividnext.sodalive.common.ApiResponse
 | 
					import kr.co.vividnext.sodalive.common.ApiResponse
 | 
				
			||||||
import java.util.concurrent.Callable
 | 
					import java.util.concurrent.Callable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -30,7 +32,7 @@ class ChatRepository(
 | 
				
			|||||||
        roomId: Long,
 | 
					        roomId: Long,
 | 
				
			||||||
        localId: String,
 | 
					        localId: String,
 | 
				
			||||||
        content: String
 | 
					        content: String
 | 
				
			||||||
    ): Single<List<ServerChatMessage>> {
 | 
					    ): Single<SendChatMessageResponse> {
 | 
				
			||||||
        return talkApi.sendMessage(
 | 
					        return talkApi.sendMessage(
 | 
				
			||||||
            authHeader = token,
 | 
					            authHeader = token,
 | 
				
			||||||
            roomId = roomId,
 | 
					            roomId = roomId,
 | 
				
			||||||
@@ -38,21 +40,36 @@ class ChatRepository(
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
            .subscribeOn(Schedulers.io())
 | 
					            .subscribeOn(Schedulers.io())
 | 
				
			||||||
            .map { ensureSuccess(it) }
 | 
					            .map { ensureSuccess(it) }
 | 
				
			||||||
            .flatMap { serverMsgs ->
 | 
					            .flatMap { response ->
 | 
				
			||||||
                // 1) 로컬에 사용자 메시지 상태를 SENT로 업데이트
 | 
					                // 1) 로컬에 사용자 메시지 상태를 SENT로 업데이트
 | 
				
			||||||
                val updateStatus = Completable.fromAction {
 | 
					                val updateStatus = Completable.fromAction {
 | 
				
			||||||
                    kotlinx.coroutines.runBlocking { chatDao.updateStatusByLocalId(roomId, localId, MessageStatus.SENT.name) }
 | 
					                    kotlinx.coroutines.runBlocking { chatDao.updateStatusByLocalId(roomId, localId, MessageStatus.SENT.name) }
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                // 2) 서버 응답 메시지들을 로컬 DB에 저장(중복 방지: 동일 ID는 REPLACE)
 | 
					                // 2) 서버 응답 메시지들을 로컬 DB에 저장(중복 방지: 동일 ID는 REPLACE)
 | 
				
			||||||
                val insertServers = Completable.fromAction {
 | 
					                val insertServers = Completable.fromAction {
 | 
				
			||||||
                    val entities = serverMsgs.map { it.toEntity(roomId) }
 | 
					                    val entities = response.messages.map { it.toEntity(roomId) }
 | 
				
			||||||
                    kotlinx.coroutines.runBlocking { chatDao.insertMessages(entities) }
 | 
					                    kotlinx.coroutines.runBlocking { chatDao.insertMessages(entities) }
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                updateStatus.andThen(insertServers)
 | 
					                updateStatus.andThen(insertServers)
 | 
				
			||||||
                    .andThen(Single.just(serverMsgs))
 | 
					                    .andThen(Single.just(response))
 | 
				
			||||||
                    .subscribeOn(Schedulers.io())
 | 
					                    .subscribeOn(Schedulers.io())
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /** 쿼터 상태 조회 */
 | 
				
			||||||
 | 
					    fun getChatQuotaStatus(token: String): Single<ChatQuotaStatusResponse> {
 | 
				
			||||||
 | 
					        return talkApi.getChatQuotaStatus(authHeader = token)
 | 
				
			||||||
 | 
					            .subscribeOn(Schedulers.io())
 | 
				
			||||||
 | 
					            .map { ensureSuccess(it) }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /** 쿼터 구매 */
 | 
				
			||||||
 | 
					    fun purchaseChatQuota(token: String): Single<ChatQuotaStatusResponse> {
 | 
				
			||||||
 | 
					        return talkApi.purchaseChatQuota(authHeader = token, request = ChatQuotaPurchaseRequest())
 | 
				
			||||||
 | 
					            .subscribeOn(Schedulers.io())
 | 
				
			||||||
 | 
					            .map { ensureSuccess(it) }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * 로컬에서 최근 20개 메시지 조회
 | 
					     * 로컬에서 최근 20개 메시지 조회
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,6 +13,7 @@ import androidx.core.content.edit
 | 
				
			|||||||
import androidx.core.view.isVisible
 | 
					import androidx.core.view.isVisible
 | 
				
			||||||
import androidx.recyclerview.widget.LinearLayoutManager
 | 
					import androidx.recyclerview.widget.LinearLayoutManager
 | 
				
			||||||
import androidx.recyclerview.widget.RecyclerView
 | 
					import androidx.recyclerview.widget.RecyclerView
 | 
				
			||||||
 | 
					import androidx.recyclerview.widget.SimpleItemAnimator
 | 
				
			||||||
import coil.load
 | 
					import coil.load
 | 
				
			||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
 | 
					import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
 | 
				
			||||||
import kr.co.vividnext.sodalive.R
 | 
					import kr.co.vividnext.sodalive.R
 | 
				
			||||||
@@ -24,10 +25,12 @@ import kr.co.vividnext.sodalive.databinding.ActivityChatRoomBinding
 | 
				
			|||||||
import kr.co.vividnext.sodalive.extensions.dpToPx
 | 
					import kr.co.vividnext.sodalive.extensions.dpToPx
 | 
				
			||||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
 | 
					import kr.co.vividnext.sodalive.extensions.moneyFormat
 | 
				
			||||||
import org.koin.android.ext.android.inject
 | 
					import org.koin.android.ext.android.inject
 | 
				
			||||||
 | 
					import java.util.Locale
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
					class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			||||||
    ActivityChatRoomBinding::inflate
 | 
					    ActivityChatRoomBinding::inflate
 | 
				
			||||||
) {
 | 
					) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private var roomId: Long = 0L
 | 
					    private var roomId: Long = 0L
 | 
				
			||||||
    private lateinit var chatAdapter: ChatMessageAdapter
 | 
					    private lateinit var chatAdapter: ChatMessageAdapter
 | 
				
			||||||
    private lateinit var layoutManager: LinearLayoutManager
 | 
					    private lateinit var layoutManager: LinearLayoutManager
 | 
				
			||||||
@@ -41,6 +44,9 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			|||||||
    private var hasMoreMessages: Boolean = true // Repository 연동 시 서버 값으로 갱신 예정
 | 
					    private var hasMoreMessages: Boolean = true // Repository 연동 시 서버 값으로 갱신 예정
 | 
				
			||||||
    private var nextCursor: Long? = null // 가장 오래된 메시지의 timestamp 등
 | 
					    private var nextCursor: Long? = null // 가장 오래된 메시지의 timestamp 등
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 쿼터/카운트다운 상태
 | 
				
			||||||
 | 
					    private var quotaTimer: android.os.CountDownTimer? = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관)
 | 
					    // 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관)
 | 
				
			||||||
    private var characterInfo: CharacterInfo? = null
 | 
					    private var characterInfo: CharacterInfo? = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -80,7 +86,8 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        // 더보기 클릭 시 전체화면 다이얼로그 표시
 | 
					        // 더보기 클릭 시 전체화면 다이얼로그 표시
 | 
				
			||||||
        binding.ivMore.setOnClickListener {
 | 
					        binding.ivMore.setOnClickListener {
 | 
				
			||||||
            ChatRoomMoreDialogFragment.newInstance(roomId).show(supportFragmentManager, "ChatRoomMoreDialog")
 | 
					            ChatRoomMoreDialogFragment.newInstance(roomId)
 | 
				
			||||||
 | 
					                .show(supportFragmentManager, "ChatRoomMoreDialog")
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // 5.3: characterInfo가 있으면 헤더 바인딩, 없으면 기본 플레이스홀더 유지
 | 
					        // 5.3: characterInfo가 있으면 헤더 바인딩, 없으면 기본 플레이스홀더 유지
 | 
				
			||||||
@@ -184,10 +191,17 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			|||||||
                override fun onOpenPurchasedImage(message: ChatMessage) {
 | 
					                override fun onOpenPurchasedImage(message: ChatMessage) {
 | 
				
			||||||
                    openPurchasedImageCarousel(message)
 | 
					                    openPurchasedImageCarousel(message)
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                override fun onPurchaseQuota() {
 | 
				
			||||||
 | 
					                    onPurchaseQuotaClicked()
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
            })
 | 
					            })
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        binding.rvMessages.adapter = chatAdapter
 | 
					        binding.rvMessages.adapter = chatAdapter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Change 애니메이션 비활성화로 매초 부분 갱신 시 깜빡임 최소화
 | 
				
			||||||
 | 
					        (binding.rvMessages.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // 현재 보유 중인 캐릭터 프로필/이름을 타이핑 인디케이터에도 반영
 | 
					        // 현재 보유 중인 캐릭터 프로필/이름을 타이핑 인디케이터에도 반영
 | 
				
			||||||
        chatAdapter.setTypingInfo(
 | 
					        chatAdapter.setTypingInfo(
 | 
				
			||||||
            characterInfo?.name,
 | 
					            characterInfo?.name,
 | 
				
			||||||
@@ -348,18 +362,21 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			|||||||
            content = content
 | 
					            content = content
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
            .observeOn(AndroidSchedulers.mainThread())
 | 
					            .observeOn(AndroidSchedulers.mainThread())
 | 
				
			||||||
            .subscribe({ serverMsgs ->
 | 
					            .subscribe({ response ->
 | 
				
			||||||
                // 성공: 타이핑 인디케이터 제거 및 상태 업데이트
 | 
					                // 성공: 타이핑 인디케이터 제거 및 상태 업데이트
 | 
				
			||||||
                chatAdapter.hideTypingIndicator()
 | 
					                chatAdapter.hideTypingIndicator()
 | 
				
			||||||
                updateUserMessageStatus(localId, MessageStatus.SENT)
 | 
					                updateUserMessageStatus(localId, MessageStatus.SENT)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                // 서버 응답이 여러 개인 경우, mine == false(AI)만 순서대로 추가
 | 
					                // 서버 응답이 여러 개인 경우, mine == false(AI)만 순서대로 추가
 | 
				
			||||||
                serverMsgs.forEach { msg ->
 | 
					                response.messages.forEach { msg ->
 | 
				
			||||||
                    val domain = msg.toDomain()
 | 
					                    val domain = msg.toDomain()
 | 
				
			||||||
                    if (!domain.mine) {
 | 
					                    if (!domain.mine) {
 | 
				
			||||||
                        appendMessage(ChatListItem.AiMessage(domain, characterInfo?.name))
 | 
					                        appendMessage(ChatListItem.AiMessage(domain, characterInfo?.name))
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // 응답에 포함된 쿼터 상태로 UI 갱신
 | 
				
			||||||
 | 
					                updateQuotaUi(response.totalRemaining, response.nextRechargeAtEpoch)
 | 
				
			||||||
            }, { error ->
 | 
					            }, { error ->
 | 
				
			||||||
                // 실패: 타이핑 인디케이터 제거 및 FAILED로 업데이트
 | 
					                // 실패: 타이핑 인디케이터 제거 및 FAILED로 업데이트
 | 
				
			||||||
                chatAdapter.hideTypingIndicator()
 | 
					                chatAdapter.hideTypingIndicator()
 | 
				
			||||||
@@ -438,6 +455,133 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
    // endregion 6.2 Send flow
 | 
					    // endregion 6.2 Send flow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // region Quota handling
 | 
				
			||||||
 | 
					    private fun onPurchaseQuotaClicked() {
 | 
				
			||||||
 | 
					        val token = "Bearer ${SharedPreferenceManager.token}"
 | 
				
			||||||
 | 
					        compositeDisposable.add(
 | 
				
			||||||
 | 
					            chatRepository.purchaseChatQuota(token)
 | 
				
			||||||
 | 
					                .observeOn(AndroidSchedulers.mainThread())
 | 
				
			||||||
 | 
					                .subscribe({ resp ->
 | 
				
			||||||
 | 
					                    // 쿼터 UI 갱신
 | 
				
			||||||
 | 
					                    updateQuotaUi(resp.totalRemaining, resp.nextRechargeAtEpoch)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    // 결제 성공 시 로컬 캔 차감(30캔) 및 헤더 배지 즉시 반영
 | 
				
			||||||
 | 
					                    val newCan = (SharedPreferenceManager.can - 30).coerceAtLeast(0)
 | 
				
			||||||
 | 
					                    SharedPreferenceManager.can = newCan
 | 
				
			||||||
 | 
					                    binding.tvCanBadge.text = newCan.moneyFormat()
 | 
				
			||||||
 | 
					                }, { err ->
 | 
				
			||||||
 | 
					                    showToast(err.message ?: "결제에 실패했습니다.")
 | 
				
			||||||
 | 
					                })
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private fun updateQuotaUi(totalRemaining: Int, nextRechargeAtEpoch: Long?) {
 | 
				
			||||||
 | 
					        if (totalRemaining > 0) {
 | 
				
			||||||
 | 
					            // 입력창 표시 및 안내 제거
 | 
				
			||||||
 | 
					            binding.inputContainer.isVisible = true
 | 
				
			||||||
 | 
					            stopQuotaCountdown()
 | 
				
			||||||
 | 
					            ensureQuotaNoticeRemoved()
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            // 입력창 숨김 및 안내 표시 + 카운트다운 시작
 | 
				
			||||||
 | 
					            binding.inputContainer.isVisible = false
 | 
				
			||||||
 | 
					            val timeText = formatEpochToHms(nextRechargeAtEpoch)
 | 
				
			||||||
 | 
					            ensureQuotaNoticeShown(timeText)
 | 
				
			||||||
 | 
					            startQuotaCountdown(nextRechargeAtEpoch)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private fun ensureQuotaNoticeShown(timeText: String?) {
 | 
				
			||||||
 | 
					        val idx = items.indexOfLast { it is ChatListItem.QuotaNotice }
 | 
				
			||||||
 | 
					        val newItem = ChatListItem.QuotaNotice(timeText = timeText)
 | 
				
			||||||
 | 
					        if (idx >= 0) {
 | 
				
			||||||
 | 
					            val old = items[idx] as ChatListItem.QuotaNotice
 | 
				
			||||||
 | 
					            // 동일 시간 텍스트면 불필요한 갱신 회피
 | 
				
			||||||
 | 
					            if (old.timeText == newItem.timeText) return
 | 
				
			||||||
 | 
					            items[idx] = newItem
 | 
				
			||||||
 | 
					            chatAdapter.setItems(items)
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					            appendMessage(newItem)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private fun ensureQuotaNoticeRemoved() {
 | 
				
			||||||
 | 
					        val idx = items.indexOfLast { it is ChatListItem.QuotaNotice }
 | 
				
			||||||
 | 
					        if (idx >= 0) {
 | 
				
			||||||
 | 
					            items.removeAt(idx)
 | 
				
			||||||
 | 
					            chatAdapter.setItems(items)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private fun startQuotaCountdown(targetEpoch: Long?) {
 | 
				
			||||||
 | 
					        stopQuotaCountdown()
 | 
				
			||||||
 | 
					        if (targetEpoch == null) return
 | 
				
			||||||
 | 
					        val targetMs = if (targetEpoch < 1_000_000_000_000L) targetEpoch * 1000 else targetEpoch
 | 
				
			||||||
 | 
					        val now = System.currentTimeMillis()
 | 
				
			||||||
 | 
					        val duration = targetMs - now
 | 
				
			||||||
 | 
					        if (duration <= 0) {
 | 
				
			||||||
 | 
					            checkQuotaStatus()
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        quotaTimer = object : android.os.CountDownTimer(duration, 1000L) {
 | 
				
			||||||
 | 
					            override fun onTick(millisUntilFinished: Long) {
 | 
				
			||||||
 | 
					                val timeText =
 | 
				
			||||||
 | 
					                    formatMillisToHms((millisUntilFinished + DISPLAY_FUDGE_MS).coerceAtLeast(0L))
 | 
				
			||||||
 | 
					                // 안내 갱신
 | 
				
			||||||
 | 
					                ensureQuotaNoticeShown(timeText)
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            override fun onFinish() {
 | 
				
			||||||
 | 
					                ensureQuotaNoticeShown("00:00:00")
 | 
				
			||||||
 | 
					                checkQuotaStatus()
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }.start()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private fun stopQuotaCountdown() {
 | 
				
			||||||
 | 
					        quotaTimer?.cancel()
 | 
				
			||||||
 | 
					        quotaTimer = null
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private fun checkQuotaStatus() {
 | 
				
			||||||
 | 
					        val token = "Bearer ${SharedPreferenceManager.token}"
 | 
				
			||||||
 | 
					        compositeDisposable.add(
 | 
				
			||||||
 | 
					            chatRepository.getChatQuotaStatus(token)
 | 
				
			||||||
 | 
					                .observeOn(AndroidSchedulers.mainThread())
 | 
				
			||||||
 | 
					                .subscribe({ resp ->
 | 
				
			||||||
 | 
					                    updateQuotaUi(resp.totalRemaining, resp.nextRechargeAtEpoch)
 | 
				
			||||||
 | 
					                }, { /* 무시: 다음 틱에 재시도 가능 */ })
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private fun formatEpochToHms(epoch: Long?): String? {
 | 
				
			||||||
 | 
					        if (epoch == null) return null
 | 
				
			||||||
 | 
					        val ms = if (epoch < 1_000_000_000_000L) epoch * 1000 else epoch
 | 
				
			||||||
 | 
					        val remain = ms - System.currentTimeMillis()
 | 
				
			||||||
 | 
					        val displayMs = (remain + DISPLAY_FUDGE_MS).coerceAtLeast(0L)
 | 
				
			||||||
 | 
					        return if (displayMs > 0L) formatMillisToHms(displayMs) else "00:00:00"
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private fun formatMillisToHms(ms: Long): String {
 | 
				
			||||||
 | 
					        var totalSec = (ms / 1000).coerceAtLeast(0)
 | 
				
			||||||
 | 
					        val hours = totalSec / 3600
 | 
				
			||||||
 | 
					        totalSec %= 3600
 | 
				
			||||||
 | 
					        val minutes = totalSec / 60
 | 
				
			||||||
 | 
					        val seconds = totalSec % 60
 | 
				
			||||||
 | 
					        return String.format(
 | 
				
			||||||
 | 
					            locale = Locale.getDefault(),
 | 
				
			||||||
 | 
					            "%02d:%02d:%02d",
 | 
				
			||||||
 | 
					            hours,
 | 
				
			||||||
 | 
					            minutes,
 | 
				
			||||||
 | 
					            seconds
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    override fun onDestroy() {
 | 
				
			||||||
 | 
					        stopQuotaCountdown()
 | 
				
			||||||
 | 
					        super.onDestroy()
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    // endregion Quota handling
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private fun loadInitialMessages() {
 | 
					    private fun loadInitialMessages() {
 | 
				
			||||||
        // 7.1 보완: 로컬 우선 표시 + 서버 동기화
 | 
					        // 7.1 보완: 로컬 우선 표시 + 서버 동기화
 | 
				
			||||||
        isLoading = true
 | 
					        isLoading = true
 | 
				
			||||||
@@ -494,6 +638,9 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			|||||||
                scrollToBottom()
 | 
					                scrollToBottom()
 | 
				
			||||||
                isLoading = false
 | 
					                isLoading = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // 쿼터 UI 갱신
 | 
				
			||||||
 | 
					                updateQuotaUi(response.totalRemaining, response.nextRechargeAtEpoch)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                // 7.3: 오래된 메시지 정리(백그라운드)
 | 
					                // 7.3: 오래된 메시지 정리(백그라운드)
 | 
				
			||||||
                compositeDisposable.add(
 | 
					                compositeDisposable.add(
 | 
				
			||||||
                    chatRepository.trimOldMessages(roomId, keepLatest = 200)
 | 
					                    chatRepository.trimOldMessages(roomId, keepLatest = 200)
 | 
				
			||||||
@@ -721,6 +868,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    companion object {
 | 
					    companion object {
 | 
				
			||||||
 | 
					        private const val DISPLAY_FUDGE_MS: Long = 5_000L
 | 
				
			||||||
        const val EXTRA_ROOM_ID: String = "extra_room_id"
 | 
					        const val EXTRA_ROOM_ID: String = "extra_room_id"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        fun newIntent(context: Context, roomId: Long): Intent {
 | 
					        fun newIntent(context: Context, roomId: Long): Intent {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,5 +11,7 @@ data class ChatRoomEnterResponse(
 | 
				
			|||||||
    @SerializedName("roomId") val roomId: Long,
 | 
					    @SerializedName("roomId") val roomId: Long,
 | 
				
			||||||
    @SerializedName("character") val character: CharacterInfo,
 | 
					    @SerializedName("character") val character: CharacterInfo,
 | 
				
			||||||
    @SerializedName("messages") val messages: List<ServerChatMessage>,
 | 
					    @SerializedName("messages") val messages: List<ServerChatMessage>,
 | 
				
			||||||
    @SerializedName("hasMoreMessages") val hasMoreMessages: Boolean
 | 
					    @SerializedName("hasMoreMessages") val hasMoreMessages: Boolean,
 | 
				
			||||||
 | 
					    @SerializedName("totalRemaining") val totalRemaining: Int = 0,
 | 
				
			||||||
 | 
					    @SerializedName("nextRechargeAtEpoch") val nextRechargeAtEpoch: Long? = null
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -0,0 +1,11 @@
 | 
				
			|||||||
 | 
					package kr.co.vividnext.sodalive.chat.talk.room
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import androidx.annotation.Keep
 | 
				
			||||||
 | 
					import com.google.gson.annotations.SerializedName
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Keep
 | 
				
			||||||
 | 
					data class SendChatMessageResponse(
 | 
				
			||||||
 | 
					    @SerializedName("messages") val messages: List<ServerChatMessage>,
 | 
				
			||||||
 | 
					    @SerializedName("totalRemaining") val totalRemaining: Int = 0,
 | 
				
			||||||
 | 
					    @SerializedName("nextRechargeAtEpoch") val nextRechargeAtEpoch: Long? = null
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					package kr.co.vividnext.sodalive.chat.talk.room.quota
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import androidx.annotation.Keep
 | 
				
			||||||
 | 
					import com.google.gson.annotations.SerializedName
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Keep
 | 
				
			||||||
 | 
					data class ChatQuotaPurchaseRequest(
 | 
				
			||||||
 | 
					    @SerializedName("container") val container: String = "aos"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					package kr.co.vividnext.sodalive.chat.talk.room.quota
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import androidx.annotation.Keep
 | 
				
			||||||
 | 
					import com.google.gson.annotations.SerializedName
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Keep
 | 
				
			||||||
 | 
					data class ChatQuotaStatusResponse(
 | 
				
			||||||
 | 
					    @SerializedName("totalRemaining") val totalRemaining: Int,
 | 
				
			||||||
 | 
					    @SerializedName("nextRechargeAtEpoch") val nextRechargeAtEpoch: Long?
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/drawable-mdpi/ic_time.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/src/main/res/drawable-mdpi/ic_time.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 578 B  | 
							
								
								
									
										6
									
								
								app/src/main/res/drawable/bg_chat_notice_quota.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								app/src/main/res/drawable/bg_chat_notice_quota.xml
									
									
									
									
									
										Normal 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="#EC8280" />
 | 
				
			||||||
 | 
					    <corners android:radius="10dp" />
 | 
				
			||||||
 | 
					</shape>
 | 
				
			||||||
							
								
								
									
										81
									
								
								app/src/main/res/layout/item_chat_quota_notice.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								app/src/main/res/layout/item_chat_quota_notice.xml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
				
			|||||||
 | 
					<?xml version="1.0" encoding="utf-8"?>
 | 
				
			||||||
 | 
					<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 | 
				
			||||||
 | 
					    xmlns:tools="http://schemas.android.com/tools"
 | 
				
			||||||
 | 
					    android:layout_width="match_parent"
 | 
				
			||||||
 | 
					    android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					    android:orientation="vertical"
 | 
				
			||||||
 | 
					    android:paddingVertical="20dp">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <LinearLayout
 | 
				
			||||||
 | 
					        android:layout_width="match_parent"
 | 
				
			||||||
 | 
					        android:layout_height="120dp"
 | 
				
			||||||
 | 
					        android:background="@drawable/bg_chat_notice_quota"
 | 
				
			||||||
 | 
					        android:gravity="center"
 | 
				
			||||||
 | 
					        android:orientation="vertical">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <ImageView
 | 
				
			||||||
 | 
					            android:id="@+id/iv_icon"
 | 
				
			||||||
 | 
					            android:layout_width="30dp"
 | 
				
			||||||
 | 
					            android:layout_height="30dp"
 | 
				
			||||||
 | 
					            android:contentDescription="@null"
 | 
				
			||||||
 | 
					            android:src="@drawable/ic_time" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <TextView
 | 
				
			||||||
 | 
					            android:id="@+id/tv_time"
 | 
				
			||||||
 | 
					            android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_marginTop="8dp"
 | 
				
			||||||
 | 
					            android:fontFamily="@font/pretendard_bold"
 | 
				
			||||||
 | 
					            android:textColor="@color/white"
 | 
				
			||||||
 | 
					            android:textSize="18sp"
 | 
				
			||||||
 | 
					            tools:text="--:--:--" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <TextView
 | 
				
			||||||
 | 
					            android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_marginTop="8dp"
 | 
				
			||||||
 | 
					            android:fontFamily="@font/pretendard_bold"
 | 
				
			||||||
 | 
					            android:text="기다리면 무료 이용이 가능합니다."
 | 
				
			||||||
 | 
					            android:textColor="@color/white"
 | 
				
			||||||
 | 
					            android:textSize="18sp" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    </LinearLayout>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <LinearLayout
 | 
				
			||||||
 | 
					        android:id="@+id/ll_purchase"
 | 
				
			||||||
 | 
					        android:layout_width="match_parent"
 | 
				
			||||||
 | 
					        android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					        android:layout_marginTop="10dp"
 | 
				
			||||||
 | 
					        android:background="@drawable/bg_buy_button"
 | 
				
			||||||
 | 
					        android:gravity="center"
 | 
				
			||||||
 | 
					        android:orientation="horizontal"
 | 
				
			||||||
 | 
					        android:paddingVertical="12dp">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <ImageView
 | 
				
			||||||
 | 
					            android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_marginEnd="4dp"
 | 
				
			||||||
 | 
					            android:contentDescription="@null"
 | 
				
			||||||
 | 
					            android:src="@drawable/ic_can" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <TextView
 | 
				
			||||||
 | 
					            android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_marginEnd="8dp"
 | 
				
			||||||
 | 
					            android:fontFamily="@font/pretendard_bold"
 | 
				
			||||||
 | 
					            android:includeFontPadding="false"
 | 
				
			||||||
 | 
					            android:text="30"
 | 
				
			||||||
 | 
					            android:textColor="#263238"
 | 
				
			||||||
 | 
					            android:textSize="24sp" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <TextView
 | 
				
			||||||
 | 
					            android:layout_width="wrap_content"
 | 
				
			||||||
 | 
					            android:layout_height="wrap_content"
 | 
				
			||||||
 | 
					            android:fontFamily="@font/pretendard_bold"
 | 
				
			||||||
 | 
					            android:includeFontPadding="false"
 | 
				
			||||||
 | 
					            android:text="결제하고 바로 대화 시작"
 | 
				
			||||||
 | 
					            android:textColor="#263238"
 | 
				
			||||||
 | 
					            android:textSize="18sp" />
 | 
				
			||||||
 | 
					    </LinearLayout>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</LinearLayout>
 | 
				
			||||||
		Reference in New Issue
	
	Block a user