feat(chat): AI 유료/이미지 메시지 및 구매 플로우 추가
- ServerChatMessage/ChatMessage에 messageType/imageUrl/price/hasAccess 필드 반영 - TalkApi/Repository: 유료 메시지 구매 API 연동 및 성공 시 로컬 DB 반영 - ChatRoomActivity: 구매 팝업 SodaDialog 적용(취소/잠금해제) 및 구매 성공 시 메시지 교체 - ChatMessageAdapter: 이미지 렌더링(라운드 10dp), 유료 오버레이(가격+"눌러서 잠금해제") 처리, 구매/캐러셀 오픈 콜백 추가 - 구매된 이미지 클릭 시 전체화면 캐러셀 지원 - item_chat_ai_message.xml: 메시지 UI 최대 90% 폭, 시간 텍스트 배치 개선, 이미지 4:5 비율 적용 - 그룹 메시지 간 간격 절반 적용(ItemDecoration) - Room DB v2 마이그레이션: messageType/imageUrl/price/hasAccess 컬럼 추가로 재입장 시 표시 문제 해결 왜: - 유료/이미지 메시지 기능 제공 및 일관된 구매 경험 필요 - 재입장 시 이미지/유료 정보 누락 문제(DB 정합) 해결 - 시간 잘림/배치 문제와 그룹 간격 시인성 개선
This commit is contained in:
		@@ -14,6 +14,7 @@ import retrofit2.http.Header
 | 
			
		||||
import retrofit2.http.POST
 | 
			
		||||
import retrofit2.http.Path
 | 
			
		||||
import retrofit2.http.Query
 | 
			
		||||
import kr.co.vividnext.sodalive.chat.talk.room.ChatMessagePurchaseRequest
 | 
			
		||||
 | 
			
		||||
interface TalkApi {
 | 
			
		||||
    @GET("/api/chat/room/list")
 | 
			
		||||
@@ -50,4 +51,13 @@ interface TalkApi {
 | 
			
		||||
        @Query("cursor") cursor: Long?,
 | 
			
		||||
        @Query("limit") limit: Int = 20
 | 
			
		||||
    ): Single<ApiResponse<ChatMessagesResponse>>
 | 
			
		||||
 | 
			
		||||
    // 유료 메시지 구매 API
 | 
			
		||||
    @POST("/api/chat/room/{roomId}/messages/{messageId}/purchase")
 | 
			
		||||
    fun purchaseMessage(
 | 
			
		||||
        @Header("Authorization") authHeader: String,
 | 
			
		||||
        @Path("roomId") roomId: Long,
 | 
			
		||||
        @Path("messageId") messageId: Long,
 | 
			
		||||
        @Body request: ChatMessagePurchaseRequest
 | 
			
		||||
    ): Single<ApiResponse<ServerChatMessage>>
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ import com.google.gson.annotations.SerializedName
 | 
			
		||||
 * - status: 기본값 SENT (서버에서 내려오는 메시지)
 | 
			
		||||
 * - localId: 서버 전송 전 임시 식별자 (전송 중 로컬 식별용)
 | 
			
		||||
 * - isGrouped: UI 그룹화 처리용 클라이언트 플래그
 | 
			
		||||
 * - messageType/imageUrl/price/hasAccess: ServerChatMessage와 정합 유지
 | 
			
		||||
 */
 | 
			
		||||
@Keep
 | 
			
		||||
data class ChatMessage(
 | 
			
		||||
@@ -22,6 +23,11 @@ data class ChatMessage(
 | 
			
		||||
    @SerializedName("createdAt") val createdAt: Long,
 | 
			
		||||
    @SerializedName("status") val status: MessageStatus = MessageStatus.SENT,
 | 
			
		||||
    @SerializedName("localId") val localId: String? = null,
 | 
			
		||||
    // 서버 필드는 아니지만 UI 로직 편의를 위해 포함 (직렬화 제외 가능)
 | 
			
		||||
    // 서버 정합 필드
 | 
			
		||||
    @SerializedName("messageType") val messageType: String = "",
 | 
			
		||||
    @SerializedName("imageUrl") val imageUrl: String? = null,
 | 
			
		||||
    @SerializedName("price") val price: Int? = null,
 | 
			
		||||
    @SerializedName("hasAccess") val hasAccess: Boolean = true,
 | 
			
		||||
    // 클라이언트 전용 UI 플래그
 | 
			
		||||
    val isGrouped: Boolean = false
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
@@ -54,6 +54,8 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
			
		||||
 | 
			
		||||
    interface Callback {
 | 
			
		||||
        fun onRetrySend(localId: String)
 | 
			
		||||
        fun onPurchaseMessage(message: ChatMessage)
 | 
			
		||||
        fun onOpenPurchasedImage(message: ChatMessage)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private var callback: Callback? = null
 | 
			
		||||
@@ -205,6 +207,41 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
			
		||||
 | 
			
		||||
    override fun getItemCount(): Int = items.size
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * ItemDecoration 등에서 사용할 동일 발신자 여부 헬퍼
 | 
			
		||||
     * - 현재(position)와 이전 항목이 동일 발신자인지
 | 
			
		||||
     */
 | 
			
		||||
    fun isSameSenderWithPrev(position: Int): Boolean {
 | 
			
		||||
        if (position <= 0 || position >= items.size) return false
 | 
			
		||||
        val prev = items[position - 1]
 | 
			
		||||
        val curr = items[position]
 | 
			
		||||
        fun mineOf(item: ChatListItem): Boolean? = when (item) {
 | 
			
		||||
            is ChatListItem.UserMessage -> item.data.mine
 | 
			
		||||
            is ChatListItem.AiMessage -> item.data.mine
 | 
			
		||||
            else -> null
 | 
			
		||||
        }
 | 
			
		||||
        val p = mineOf(prev)
 | 
			
		||||
        val c = mineOf(curr)
 | 
			
		||||
        return (p != null && c != null && p == c)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 현재(position)와 다음 항목이 동일 발신자인지
 | 
			
		||||
     */
 | 
			
		||||
    fun isSameSenderWithNext(position: Int): Boolean {
 | 
			
		||||
        if (position < 0 || position >= items.size - 1) return false
 | 
			
		||||
        val curr = items[position]
 | 
			
		||||
        val next = items[position + 1]
 | 
			
		||||
        fun mineOf(item: ChatListItem): Boolean? = when (item) {
 | 
			
		||||
            is ChatListItem.UserMessage -> item.data.mine
 | 
			
		||||
            is ChatListItem.AiMessage -> item.data.mine
 | 
			
		||||
            else -> null
 | 
			
		||||
        }
 | 
			
		||||
        val c = mineOf(curr)
 | 
			
		||||
        val n = mineOf(next)
 | 
			
		||||
        return (c != null && n != null && c == n)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun getItemViewType(position: Int): Int {
 | 
			
		||||
        return when (items[position]) {
 | 
			
		||||
            is ChatListItem.UserMessage -> VIEW_TYPE_USER_MESSAGE
 | 
			
		||||
@@ -380,8 +417,6 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
			
		||||
        private val binding: ItemChatAiMessageBinding
 | 
			
		||||
    ) : RecyclerView.ViewHolder(binding.root) {
 | 
			
		||||
        fun bind(data: ChatMessage, displayName: String?, isGrouped: Boolean, showTime: Boolean) {
 | 
			
		||||
            binding.tvMessage.text = data.message
 | 
			
		||||
            binding.tvMessage.maxWidth = (itemView.resources.displayMetrics.widthPixels * 0.65f).toInt()
 | 
			
		||||
            binding.tvTime.text = formatMessageTime(data.createdAt)
 | 
			
		||||
            binding.tvTime.visibility = if (showTime) View.VISIBLE else View.GONE
 | 
			
		||||
 | 
			
		||||
@@ -402,12 +437,57 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 콘텐츠 타입 분기: 이미지 또는 텍스트
 | 
			
		||||
            val isImageMsg = !data.imageUrl.isNullOrBlank()
 | 
			
		||||
            if (isImageMsg) {
 | 
			
		||||
                // 이미지 메시지: 텍스트 버블 미사용, 이미지 뷰 표시 + 10dp 라운드 코너
 | 
			
		||||
                binding.messageContainer.visibility = View.GONE
 | 
			
		||||
                binding.imageContainer.visibility = View.VISIBLE
 | 
			
		||||
 | 
			
		||||
                val old = binding.ivImage.tag as? String
 | 
			
		||||
                if (old != data.imageUrl) {
 | 
			
		||||
                    loadRoundedImage(binding.ivImage, data.imageUrl, radiusDp = 10f)
 | 
			
		||||
                    binding.ivImage.tag = data.imageUrl
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // 유료 메시지 오버레이 처리
 | 
			
		||||
                val locked = (data.price != null) && (data.hasAccess == false)
 | 
			
		||||
                binding.llLock.isVisible = locked
 | 
			
		||||
                if (locked) {
 | 
			
		||||
                    binding.tvPrice.text = data.price?.toString() ?: ""
 | 
			
		||||
                    val clicker = View.OnClickListener {
 | 
			
		||||
                        (binding.root.parent as? RecyclerView)?.let { rv ->
 | 
			
		||||
                            (rv.adapter as? ChatMessageAdapter)?.callback?.onPurchaseMessage(data)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    binding.btnBuy.setOnClickListener(clicker)
 | 
			
		||||
                    binding.llLock.setOnClickListener(clicker)
 | 
			
		||||
                    binding.ivImage.setOnClickListener(clicker)
 | 
			
		||||
                } else {
 | 
			
		||||
                    // 구매된 이미지: 캐러셀 열기
 | 
			
		||||
                    binding.btnBuy.setOnClickListener(null)
 | 
			
		||||
                    binding.llLock.setOnClickListener(null)
 | 
			
		||||
                    binding.ivImage.setOnClickListener {
 | 
			
		||||
                        (binding.root.parent as? RecyclerView)?.let { rv ->
 | 
			
		||||
                            (rv.adapter as? ChatMessageAdapter)?.callback?.onOpenPurchasedImage(data)
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                // 텍스트 메시지
 | 
			
		||||
                binding.imageContainer.visibility = View.GONE
 | 
			
		||||
                binding.messageContainer.visibility = View.VISIBLE
 | 
			
		||||
                binding.tvMessage.text = data.message
 | 
			
		||||
                binding.tvMessage.maxWidth = (itemView.resources.displayMetrics.widthPixels * 0.90f).toInt()
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 그룹 내부 간격 최소화 (상단 패딩 축소)
 | 
			
		||||
            adjustTopPadding(isGrouped)
 | 
			
		||||
 | 
			
		||||
            // 접근성: 다국어 대응
 | 
			
		||||
            val ctx = itemView.context
 | 
			
		||||
            val messageWord = ctx.getString(R.string.a11y_message_word)
 | 
			
		||||
            val spokenMain = if (isImageMsg) "이미지" else binding.tvMessage.text
 | 
			
		||||
            itemView.contentDescription = buildString {
 | 
			
		||||
                if (!isGrouped && !binding.tvName.text.isNullOrEmpty()) {
 | 
			
		||||
                    append(binding.tvName.text)
 | 
			
		||||
@@ -415,7 +495,7 @@ class ChatMessageAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
 | 
			
		||||
                }
 | 
			
		||||
                append(messageWord)
 | 
			
		||||
                append(" ")
 | 
			
		||||
                append(binding.tvMessage.text)
 | 
			
		||||
                append(spokenMain)
 | 
			
		||||
                if (showTime) {
 | 
			
		||||
                    append(", ")
 | 
			
		||||
                    append(binding.tvTime.text)
 | 
			
		||||
 
 | 
			
		||||
@@ -16,6 +16,10 @@ fun ChatMessageEntity.toDomain(): ChatMessage {
 | 
			
		||||
        createdAt = createdAt,
 | 
			
		||||
        status = status,
 | 
			
		||||
        localId = localId,
 | 
			
		||||
        messageType = messageType,
 | 
			
		||||
        imageUrl = imageUrl,
 | 
			
		||||
        price = price,
 | 
			
		||||
        hasAccess = hasAccess,
 | 
			
		||||
        isGrouped = false
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@@ -30,7 +34,11 @@ fun ChatMessage.toEntity(roomId: Long): ChatMessageEntity {
 | 
			
		||||
        mine = mine,
 | 
			
		||||
        createdAt = createdAt,
 | 
			
		||||
        status = status,
 | 
			
		||||
        localId = localId
 | 
			
		||||
        localId = localId,
 | 
			
		||||
        messageType = messageType,
 | 
			
		||||
        imageUrl = imageUrl,
 | 
			
		||||
        price = price,
 | 
			
		||||
        hasAccess = hasAccess
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -42,7 +50,13 @@ fun ServerChatMessage.toEntity(roomId: Long): ChatMessageEntity {
 | 
			
		||||
        message = message,
 | 
			
		||||
        profileImageUrl = profileImageUrl,
 | 
			
		||||
        mine = mine,
 | 
			
		||||
        createdAt = createdAt
 | 
			
		||||
        createdAt = createdAt,
 | 
			
		||||
        status = MessageStatus.SENT,
 | 
			
		||||
        localId = null,
 | 
			
		||||
        messageType = messageType,
 | 
			
		||||
        imageUrl = imageUrl,
 | 
			
		||||
        price = price,
 | 
			
		||||
        hasAccess = hasAccess
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -56,6 +70,10 @@ fun ServerChatMessage.toDomain(): ChatMessage {
 | 
			
		||||
        createdAt = createdAt,
 | 
			
		||||
        status = MessageStatus.SENT,
 | 
			
		||||
        localId = null,
 | 
			
		||||
        messageType = messageType,
 | 
			
		||||
        imageUrl = imageUrl,
 | 
			
		||||
        price = price,
 | 
			
		||||
        hasAccess = hasAccess,
 | 
			
		||||
        isGrouped = false
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,10 @@ fun ServerChatMessage.toLocal(isGrouped: Boolean = false): ChatMessage {
 | 
			
		||||
        createdAt = createdAt,
 | 
			
		||||
        status = MessageStatus.SENT,
 | 
			
		||||
        localId = null,
 | 
			
		||||
        messageType = messageType,
 | 
			
		||||
        imageUrl = imageUrl,
 | 
			
		||||
        price = price,
 | 
			
		||||
        hasAccess = hasAccess,
 | 
			
		||||
        isGrouped = isGrouped
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,13 @@
 | 
			
		||||
package kr.co.vividnext.sodalive.chat.talk.room
 | 
			
		||||
 | 
			
		||||
import androidx.annotation.Keep
 | 
			
		||||
import com.google.gson.annotations.SerializedName
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 유료 채팅 메시지 구매 요청
 | 
			
		||||
 * - container는 AOS 고정
 | 
			
		||||
 */
 | 
			
		||||
@Keep
 | 
			
		||||
data class ChatMessagePurchaseRequest(
 | 
			
		||||
    @SerializedName("container") val container: String = "aos"
 | 
			
		||||
)
 | 
			
		||||
@@ -162,4 +162,26 @@ class ChatRepository(
 | 
			
		||||
        }
 | 
			
		||||
        return response.data
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 유료 메시지 구매
 | 
			
		||||
     * - 성공 시 서버에서 갱신된 메시지를 반환하고 로컬 DB에도 반영한다.
 | 
			
		||||
     */
 | 
			
		||||
    fun purchaseMessage(token: String, roomId: Long, messageId: Long): Single<ServerChatMessage> {
 | 
			
		||||
        return talkApi.purchaseMessage(
 | 
			
		||||
            authHeader = token,
 | 
			
		||||
            roomId = roomId,
 | 
			
		||||
            messageId = messageId,
 | 
			
		||||
            request = ChatMessagePurchaseRequest()
 | 
			
		||||
        )
 | 
			
		||||
            .subscribeOn(Schedulers.io())
 | 
			
		||||
            .map { ensureSuccess(it) }
 | 
			
		||||
            .flatMap { serverMsg ->
 | 
			
		||||
                val insert = Completable.fromAction {
 | 
			
		||||
                    val entity = serverMsg.toEntity(roomId)
 | 
			
		||||
                    kotlinx.coroutines.runBlocking { chatDao.insertMessage(entity) }
 | 
			
		||||
                }
 | 
			
		||||
                insert.andThen(Single.just(serverMsg)).subscribeOn(Schedulers.io())
 | 
			
		||||
            }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -14,8 +14,10 @@ import androidx.core.view.isVisible
 | 
			
		||||
import androidx.recyclerview.widget.LinearLayoutManager
 | 
			
		||||
import androidx.recyclerview.widget.RecyclerView
 | 
			
		||||
import coil.load
 | 
			
		||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
 | 
			
		||||
import kr.co.vividnext.sodalive.R
 | 
			
		||||
import kr.co.vividnext.sodalive.base.BaseActivity
 | 
			
		||||
import kr.co.vividnext.sodalive.base.SodaDialog
 | 
			
		||||
import kr.co.vividnext.sodalive.chat.character.detail.detail.CharacterType
 | 
			
		||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
 | 
			
		||||
import kr.co.vividnext.sodalive.databinding.ActivityChatRoomBinding
 | 
			
		||||
@@ -136,23 +138,22 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
			
		||||
            ) {
 | 
			
		||||
                super.getItemOffsets(outRect, view, parent, state)
 | 
			
		||||
 | 
			
		||||
                val position = parent.getChildAdapterPosition(view)
 | 
			
		||||
                if (position == RecyclerView.NO_POSITION) return
 | 
			
		||||
 | 
			
		||||
                val spacingNormal = 10f.dpToPx().toInt()
 | 
			
		||||
                val spacingGrouped = 5f.dpToPx().toInt()
 | 
			
		||||
 | 
			
		||||
                outRect.left = 13.3f.dpToPx().toInt()
 | 
			
		||||
                outRect.right = 13.3f.dpToPx().toInt()
 | 
			
		||||
 | 
			
		||||
                when (parent.getChildAdapterPosition(view)) {
 | 
			
		||||
                    0 -> {
 | 
			
		||||
                        outRect.top = 0
 | 
			
		||||
                        outRect.bottom = 10f.dpToPx().toInt()
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    chatAdapter.itemCount - 1 -> {
 | 
			
		||||
                        outRect.top = 10f.dpToPx().toInt()
 | 
			
		||||
                        outRect.bottom = 0
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                // 상단 간격만으로 메시지 사이 간격을 제어(중복 누적 방지)
 | 
			
		||||
                outRect.bottom = 0
 | 
			
		||||
                outRect.top = when (position) {
 | 
			
		||||
                    0 -> 0
 | 
			
		||||
                    else -> {
 | 
			
		||||
                        outRect.top = 10f.dpToPx().toInt()
 | 
			
		||||
                        outRect.bottom = 10f.dpToPx().toInt()
 | 
			
		||||
                        // 이전 항목과 동일 발신자면 그룹 간격(절반) 적용
 | 
			
		||||
                        if (chatAdapter.isSameSenderWithPrev(position)) spacingGrouped else spacingNormal
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
@@ -165,6 +166,14 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
			
		||||
                override fun onRetrySend(localId: String) {
 | 
			
		||||
                    onRetrySendClicked(localId)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                override fun onPurchaseMessage(message: ChatMessage) {
 | 
			
		||||
                    onPurchaseMessageClicked(message)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                override fun onOpenPurchasedImage(message: ChatMessage) {
 | 
			
		||||
                    openPurchasedImageCarousel(message)
 | 
			
		||||
                }
 | 
			
		||||
            })
 | 
			
		||||
        }
 | 
			
		||||
        binding.rvMessages.adapter = chatAdapter
 | 
			
		||||
@@ -328,7 +337,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
			
		||||
            localId = localId,
 | 
			
		||||
            content = content
 | 
			
		||||
        )
 | 
			
		||||
            .observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
 | 
			
		||||
            .observeOn(AndroidSchedulers.mainThread())
 | 
			
		||||
            .subscribe({ serverMsgs ->
 | 
			
		||||
                // 성공: 타이핑 인디케이터 제거 및 상태 업데이트
 | 
			
		||||
                chatAdapter.hideTypingIndicator()
 | 
			
		||||
@@ -425,11 +434,12 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
			
		||||
 | 
			
		||||
        // 1) 로컬 최근 20개 즉시 표시 (있을 경우)
 | 
			
		||||
        val localDisposable = chatRepository.getRecentMessagesFromLocal(roomId)
 | 
			
		||||
            .observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
 | 
			
		||||
            .observeOn(AndroidSchedulers.mainThread())
 | 
			
		||||
            .subscribe({ localList ->
 | 
			
		||||
                if (localList.isNotEmpty() && items.isEmpty()) {
 | 
			
		||||
                    val localItems = localList
 | 
			
		||||
                        .sortedWith(compareBy<ChatMessage> { it.createdAt }.thenBy { it.messageId }.thenBy { it.localId ?: "" })
 | 
			
		||||
                        .sortedWith(compareBy<ChatMessage> { it.createdAt }.thenBy { it.messageId }
 | 
			
		||||
                            .thenBy { it.localId ?: "" })
 | 
			
		||||
                        .map { msg ->
 | 
			
		||||
                            if (msg.mine) ChatListItem.UserMessage(msg)
 | 
			
		||||
                            else ChatListItem.AiMessage(msg, characterInfo?.name)
 | 
			
		||||
@@ -445,13 +455,14 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
			
		||||
        // 2) 서버 통합 API로 동기화 및 UI 갱신
 | 
			
		||||
        val token = "Bearer ${SharedPreferenceManager.token}"
 | 
			
		||||
        val networkDisposable = chatRepository.enterChatRoom(token = token, roomId = roomId)
 | 
			
		||||
            .observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
 | 
			
		||||
            .observeOn(AndroidSchedulers.mainThread())
 | 
			
		||||
            .subscribe({ response ->
 | 
			
		||||
                // 캐릭터 정보 바인딩
 | 
			
		||||
                setCharacterInfo(response.character)
 | 
			
		||||
 | 
			
		||||
                // 메시지 정렬(오래된 -> 최신) + 동시간대는 messageId 오름차순으로 안정화
 | 
			
		||||
                val sorted = response.messages.sortedWith(compareBy<ServerChatMessage> { it.createdAt }.thenBy { it.messageId })
 | 
			
		||||
                val sorted =
 | 
			
		||||
                    response.messages.sortedWith(compareBy<ServerChatMessage> { it.createdAt }.thenBy { it.messageId })
 | 
			
		||||
                val chatItems = sorted.map { serverMsg ->
 | 
			
		||||
                    val domain = serverMsg.toDomain()
 | 
			
		||||
                    if (domain.mine) {
 | 
			
		||||
@@ -547,10 +558,11 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
			
		||||
 | 
			
		||||
        val disposable =
 | 
			
		||||
            chatRepository.loadMoreMessages(token = token, roomId = roomId, cursor = cursor)
 | 
			
		||||
                .observeOn(io.reactivex.rxjava3.android.schedulers.AndroidSchedulers.mainThread())
 | 
			
		||||
                .observeOn(AndroidSchedulers.mainThread())
 | 
			
		||||
                .subscribe({ response ->
 | 
			
		||||
                    // 서버에서 받은 메시지(이전 것들)를 오래된 -> 최신 + 동시간대는 messageId 오름차순으로 정렬
 | 
			
		||||
                    val sorted = response.messages.sortedWith(compareBy<ServerChatMessage> { it.createdAt }.thenBy { it.messageId })
 | 
			
		||||
                    val sorted =
 | 
			
		||||
                        response.messages.sortedWith(compareBy<ServerChatMessage> { it.createdAt }.thenBy { it.messageId })
 | 
			
		||||
 | 
			
		||||
                    // 중복 제거: 기존 목록의 messageId 집합과 비교
 | 
			
		||||
                    val existingIds: Set<Long> = items.mapNotNull {
 | 
			
		||||
@@ -599,6 +611,72 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
 | 
			
		||||
 | 
			
		||||
    // endregion
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 유료 메시지 구매 클릭 처리: 팝업 확인 → 구매 API 호출 → 메시지 교체
 | 
			
		||||
     */
 | 
			
		||||
    private fun onPurchaseMessageClicked(message: ChatMessage) {
 | 
			
		||||
        // 조건 확인: AI 메시지이며 잠금 상태여야 함
 | 
			
		||||
        val isLock = (message.price != null) && (message.hasAccess == false)
 | 
			
		||||
        if (message.mine || !isLock) return
 | 
			
		||||
 | 
			
		||||
        val priceText = message.price?.toString() ?: "0"
 | 
			
		||||
        val title = "잠금된 메시지"
 | 
			
		||||
        val desc = "이 메시지를 ${priceText}캔으로 잠금해제 하시겠습니까?"
 | 
			
		||||
 | 
			
		||||
        SodaDialog(
 | 
			
		||||
            activity = this,
 | 
			
		||||
            layoutInflater = this.layoutInflater,
 | 
			
		||||
            title = title,
 | 
			
		||||
            desc = desc,
 | 
			
		||||
            confirmButtonTitle = "잠금해제",
 | 
			
		||||
            confirmButtonClick = {
 | 
			
		||||
                val token = "Bearer ${SharedPreferenceManager.token}"
 | 
			
		||||
                val disposable = chatRepository.purchaseMessage(
 | 
			
		||||
                    token = token,
 | 
			
		||||
                    roomId = roomId,
 | 
			
		||||
                    messageId = message.messageId
 | 
			
		||||
                )
 | 
			
		||||
                    .observeOn(AndroidSchedulers.mainThread())
 | 
			
		||||
                    .subscribe({ serverMsg ->
 | 
			
		||||
                        val domain = serverMsg.toDomain()
 | 
			
		||||
                        val index = items.indexOfLast {
 | 
			
		||||
                            (it is ChatListItem.AiMessage) && it.data.messageId == message.messageId
 | 
			
		||||
                        }
 | 
			
		||||
                        if (index >= 0) {
 | 
			
		||||
                            items[index] = ChatListItem.AiMessage(domain, characterInfo?.name)
 | 
			
		||||
                            chatAdapter.setItems(items)
 | 
			
		||||
                        }
 | 
			
		||||
                    }, { error ->
 | 
			
		||||
                        showToast(error.message ?: "구매에 실패했습니다.")
 | 
			
		||||
                    })
 | 
			
		||||
                compositeDisposable.add(disposable)
 | 
			
		||||
            },
 | 
			
		||||
            cancelButtonTitle = "취소",
 | 
			
		||||
            cancelButtonClick = { /* no-op */ }
 | 
			
		||||
        ).show(resources.displayMetrics.widthPixels)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun openPurchasedImageCarousel(message: ChatMessage) {
 | 
			
		||||
        // 구매된 이미지에만 캐러셀 오픈 허용
 | 
			
		||||
        val imageUrl = message.imageUrl
 | 
			
		||||
        if (imageUrl.isNullOrBlank() || !message.hasAccess) return
 | 
			
		||||
 | 
			
		||||
        // 현재 리스트에서 구매된 이미지 URL 목록 추출
 | 
			
		||||
        val urls: List<String> = items.mapNotNull {
 | 
			
		||||
            when (it) {
 | 
			
		||||
                is ChatListItem.AiMessage -> it.data.imageUrl?.takeIf { url -> it.data.hasAccess && url.isNotBlank() }
 | 
			
		||||
                else -> null
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (urls.isEmpty()) return
 | 
			
		||||
 | 
			
		||||
        val startIndex = urls.indexOf(imageUrl).let { if (it >= 0) it else 0 }
 | 
			
		||||
        val dialog =
 | 
			
		||||
            kr.co.vividnext.sodalive.chat.character.detail.gallery.CharacterGalleryViewerDialogFragment
 | 
			
		||||
                .newInstance(urls, startIndex)
 | 
			
		||||
        dialog.show(supportFragmentManager, "ChatImageCarousel")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    companion object {
 | 
			
		||||
        const val EXTRA_ROOM_ID: String = "extra_room_id"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -41,3 +41,33 @@ fun loadProfileImage(
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 일반 이미지 로딩 (둥근 모서리 적용)
 | 
			
		||||
 * - 이미지 메시지용: radiusDp = 10
 | 
			
		||||
 */
 | 
			
		||||
fun loadRoundedImage(
 | 
			
		||||
    imageView: ImageView,
 | 
			
		||||
    url: String?,
 | 
			
		||||
    radiusDp: Float = 10f,
 | 
			
		||||
    @DrawableRes placeholderRes: Int = R.drawable.ic_placeholder_profile
 | 
			
		||||
) {
 | 
			
		||||
    val density = imageView.resources.displayMetrics.density
 | 
			
		||||
    val radiusPx = radiusDp * density
 | 
			
		||||
    val targetUrl = url?.takeIf { it.isNotBlank() }
 | 
			
		||||
    if (targetUrl != null) {
 | 
			
		||||
        imageView.load(targetUrl) {
 | 
			
		||||
            placeholder(placeholderRes)
 | 
			
		||||
            error(placeholderRes)
 | 
			
		||||
            transformations(coil.transform.RoundedCornersTransformation(radiusPx))
 | 
			
		||||
            crossfade(true)
 | 
			
		||||
        }
 | 
			
		||||
    } else {
 | 
			
		||||
        imageView.load(placeholderRes) {
 | 
			
		||||
            placeholder(placeholderRes)
 | 
			
		||||
            error(placeholderRes)
 | 
			
		||||
            transformations(coil.transform.RoundedCornersTransformation(radiusPx))
 | 
			
		||||
            crossfade(true)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -16,5 +16,9 @@ data class ServerChatMessage(
 | 
			
		||||
    @SerializedName("message") val message: String,
 | 
			
		||||
    @SerializedName("profileImageUrl") val profileImageUrl: String,
 | 
			
		||||
    @SerializedName("mine") val mine: Boolean,
 | 
			
		||||
    @SerializedName("createdAt") val createdAt: Long
 | 
			
		||||
    @SerializedName("createdAt") val createdAt: Long,
 | 
			
		||||
    @SerializedName("messageType") val messageType: String = "",
 | 
			
		||||
    @SerializedName("imageUrl") val imageUrl: String? = null,
 | 
			
		||||
    @SerializedName("price") val price: Int? = null,
 | 
			
		||||
    @SerializedName("hasAccess") val hasAccess: Boolean = true
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
@@ -8,10 +8,12 @@ import androidx.room.Database
 | 
			
		||||
import androidx.room.Room
 | 
			
		||||
import androidx.room.RoomDatabase
 | 
			
		||||
import androidx.room.TypeConverters
 | 
			
		||||
import androidx.room.migration.Migration
 | 
			
		||||
import androidx.sqlite.db.SupportSQLiteDatabase
 | 
			
		||||
 | 
			
		||||
@Database(
 | 
			
		||||
    entities = [ChatMessageEntity::class],
 | 
			
		||||
    version = 1,
 | 
			
		||||
    version = 2,
 | 
			
		||||
    exportSchema = false
 | 
			
		||||
)
 | 
			
		||||
@TypeConverters(ChatMessageConverters::class)
 | 
			
		||||
@@ -22,13 +24,25 @@ abstract class ChatMessageDatabase : RoomDatabase() {
 | 
			
		||||
        @Volatile
 | 
			
		||||
        private var INSTANCE: ChatMessageDatabase? = null
 | 
			
		||||
 | 
			
		||||
        // v1 -> v2: 이미지/유료 메시지 관련 컬럼 추가
 | 
			
		||||
        private val MIGRATION_1_2 = object : Migration(1, 2) {
 | 
			
		||||
            override fun migrate(db: SupportSQLiteDatabase) {
 | 
			
		||||
                db.execSQL("ALTER TABLE chat_messages ADD COLUMN messageType TEXT NOT NULL DEFAULT ''")
 | 
			
		||||
                db.execSQL("ALTER TABLE chat_messages ADD COLUMN imageUrl TEXT")
 | 
			
		||||
                db.execSQL("ALTER TABLE chat_messages ADD COLUMN price INTEGER")
 | 
			
		||||
                db.execSQL("ALTER TABLE chat_messages ADD COLUMN hasAccess INTEGER NOT NULL DEFAULT 1")
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        fun getDatabase(context: Context): ChatMessageDatabase {
 | 
			
		||||
            return INSTANCE ?: synchronized(this) {
 | 
			
		||||
                val instance = Room.databaseBuilder(
 | 
			
		||||
                    context.applicationContext,
 | 
			
		||||
                    ChatMessageDatabase::class.java,
 | 
			
		||||
                    "chat_database"
 | 
			
		||||
                ).build()
 | 
			
		||||
                )
 | 
			
		||||
                    .addMigrations(MIGRATION_1_2)
 | 
			
		||||
                    .build()
 | 
			
		||||
                INSTANCE = instance
 | 
			
		||||
                instance
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
@@ -18,5 +18,10 @@ data class ChatMessageEntity(
 | 
			
		||||
    val mine: Boolean,
 | 
			
		||||
    val createdAt: Long,
 | 
			
		||||
    val status: MessageStatus = MessageStatus.SENT,
 | 
			
		||||
    val localId: String? = null
 | 
			
		||||
    val localId: String? = null,
 | 
			
		||||
    // 이미지/유료 메시지 관련 필드 (v2)
 | 
			
		||||
    val messageType: String = "",
 | 
			
		||||
    val imageUrl: String? = null,
 | 
			
		||||
    val price: Int? = null,
 | 
			
		||||
    val hasAccess: Boolean = true
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
@@ -29,9 +29,9 @@
 | 
			
		||||
        android:layout_height="wrap_content"
 | 
			
		||||
        android:layout_marginStart="8dp"
 | 
			
		||||
        android:orientation="vertical"
 | 
			
		||||
        app:layout_constraintEnd_toStartOf="@id/tv_time"
 | 
			
		||||
        app:layout_constraintHorizontal_bias="0"
 | 
			
		||||
        app:layout_constraintStart_toEndOf="@id/iv_profile"
 | 
			
		||||
        app:layout_constraintEnd_toStartOf="@id/tv_time"
 | 
			
		||||
        app:layout_constraintTop_toTopOf="parent">
 | 
			
		||||
 | 
			
		||||
        <!-- 보낸이 이름 (그룹의 첫 메시지에서만 보임) -->
 | 
			
		||||
@@ -74,8 +74,83 @@
 | 
			
		||||
                app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
                app:layout_constraintTop_toTopOf="parent" />
 | 
			
		||||
        </androidx.constraintlayout.widget.ConstraintLayout>
 | 
			
		||||
 | 
			
		||||
        <!-- 이미지 메시지 컨테이너 (텍스트 버블 대신 표시) -->
 | 
			
		||||
        <androidx.constraintlayout.widget.ConstraintLayout
 | 
			
		||||
            android:id="@+id/image_container"
 | 
			
		||||
            android:layout_width="match_parent"
 | 
			
		||||
            android:layout_height="wrap_content"
 | 
			
		||||
            android:visibility="gone">
 | 
			
		||||
 | 
			
		||||
            <ImageView
 | 
			
		||||
                android:id="@+id/iv_image"
 | 
			
		||||
                android:layout_width="0dp"
 | 
			
		||||
                android:layout_height="0dp"
 | 
			
		||||
                android:contentDescription="@null"
 | 
			
		||||
                android:scaleType="centerCrop"
 | 
			
		||||
                app:layout_constraintStart_toStartOf="parent"
 | 
			
		||||
                app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
                app:layout_constraintTop_toTopOf="parent"
 | 
			
		||||
                app:layout_constraintBottom_toBottomOf="parent"
 | 
			
		||||
                app:layout_constraintDimensionRatio="4:5" />
 | 
			
		||||
 | 
			
		||||
            <LinearLayout
 | 
			
		||||
                android:id="@+id/ll_lock"
 | 
			
		||||
                android:layout_width="wrap_content"
 | 
			
		||||
                android:layout_height="wrap_content"
 | 
			
		||||
                android:gravity="center"
 | 
			
		||||
                android:orientation="vertical"
 | 
			
		||||
                android:visibility="gone"
 | 
			
		||||
                app:layout_constraintTop_toTopOf="@id/iv_image"
 | 
			
		||||
                app:layout_constraintBottom_toBottomOf="@id/iv_image"
 | 
			
		||||
                app:layout_constraintStart_toStartOf="@id/iv_image"
 | 
			
		||||
                app:layout_constraintEnd_toEndOf="@id/iv_image">
 | 
			
		||||
 | 
			
		||||
                <LinearLayout
 | 
			
		||||
                    android:id="@+id/btn_buy"
 | 
			
		||||
                    android:layout_width="wrap_content"
 | 
			
		||||
                    android:layout_height="wrap_content"
 | 
			
		||||
                    android:layout_margin="10dp"
 | 
			
		||||
                    android:background="@drawable/bg_buy_button"
 | 
			
		||||
                    android:gravity="center_vertical"
 | 
			
		||||
                    android:paddingHorizontal="10dp"
 | 
			
		||||
                    android:paddingVertical="3dp">
 | 
			
		||||
 | 
			
		||||
                    <ImageView
 | 
			
		||||
                        android:layout_width="16dp"
 | 
			
		||||
                        android:layout_height="16dp"
 | 
			
		||||
                        android:layout_marginEnd="4dp"
 | 
			
		||||
                        android:contentDescription="@null"
 | 
			
		||||
                        android:src="@drawable/ic_can" />
 | 
			
		||||
 | 
			
		||||
                    <TextView
 | 
			
		||||
                        android:id="@+id/tv_price"
 | 
			
		||||
                        android:layout_width="wrap_content"
 | 
			
		||||
                        android:layout_height="wrap_content"
 | 
			
		||||
                        android:fontFamily="@font/pretendard_bold"
 | 
			
		||||
                        android:textColor="#263238"
 | 
			
		||||
                        android:textSize="16sp" />
 | 
			
		||||
                </LinearLayout>
 | 
			
		||||
 | 
			
		||||
                <TextView
 | 
			
		||||
                    android:id="@+id/tv_unlock"
 | 
			
		||||
                    android:layout_width="wrap_content"
 | 
			
		||||
                    android:layout_height="wrap_content"
 | 
			
		||||
                    android:fontFamily="@font/pretendard_bold"
 | 
			
		||||
                    android:text="눌러서 잠금해제"
 | 
			
		||||
                    android:textColor="@android:color/white"
 | 
			
		||||
                    android:textSize="18sp" />
 | 
			
		||||
            </LinearLayout>
 | 
			
		||||
        </androidx.constraintlayout.widget.ConstraintLayout>
 | 
			
		||||
    </LinearLayout>
 | 
			
		||||
 | 
			
		||||
    <androidx.constraintlayout.widget.Guideline
 | 
			
		||||
        android:id="@+id/guideline_90"
 | 
			
		||||
        android:layout_width="wrap_content"
 | 
			
		||||
        android:layout_height="wrap_content"
 | 
			
		||||
        android:orientation="vertical"
 | 
			
		||||
        app:layout_constraintGuide_percent="0.9" />
 | 
			
		||||
 | 
			
		||||
    <!-- 전송 시간: 버블의 오른쪽에 표시 (그룹의 마지막 메시지에서만 보임) -->
 | 
			
		||||
    <TextView
 | 
			
		||||
        android:id="@+id/tv_time"
 | 
			
		||||
@@ -87,7 +162,7 @@
 | 
			
		||||
        android:textColor="@color/color_777777"
 | 
			
		||||
        android:textSize="12sp"
 | 
			
		||||
        app:layout_constraintBottom_toBottomOf="@id/message_group"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="parent"
 | 
			
		||||
        app:layout_constraintStart_toEndOf="@id/message_group" />
 | 
			
		||||
        app:layout_constraintStart_toEndOf="@id/message_group"
 | 
			
		||||
        app:layout_constraintEnd_toEndOf="@id/guideline_90" />
 | 
			
		||||
 | 
			
		||||
</androidx.constraintlayout.widget.ConstraintLayout>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user