diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkApi.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkApi.kt index 04b5c66c..9f900aa7 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkApi.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkApi.kt @@ -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> + + // 유료 메시지 구매 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> } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessage.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessage.kt index 23cca8d1..0b539011 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessage.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessage.kt @@ -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 ) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt index 408f3987..aae99ece 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageAdapter.kt @@ -54,6 +54,8 @@ class ChatMessageAdapter : RecyclerView.Adapter() { 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() { 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() { 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() { } } + // 콘텐츠 타입 분기: 이미지 또는 텍스트 + 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() { } append(messageWord) append(" ") - append(binding.tvMessage.text) + append(spokenMain) if (showTime) { append(", ") append(binding.tvTime.text) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageEntityMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageEntityMappers.kt index 11ccf9d5..567ae951 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageEntityMappers.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageEntityMappers.kt @@ -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 ) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageMappers.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageMappers.kt index 75938a98..1386f5d6 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageMappers.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessageMappers.kt @@ -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 ) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessagePurchaseRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessagePurchaseRequest.kt new file mode 100644 index 00000000..3572e2c7 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatMessagePurchaseRequest.kt @@ -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" +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepository.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepository.kt index 80ae23f8..77a24623 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepository.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepository.kt @@ -162,4 +162,26 @@ class ChatRepository( } return response.data } + + /** + * 유료 메시지 구매 + * - 성공 시 서버에서 갱신된 메시지를 반환하고 로컬 DB에도 반영한다. + */ + fun purchaseMessage(token: String, roomId: Long, messageId: Long): Single { + 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()) + } + } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt index 22751052..a2529112 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt @@ -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( ) { 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( 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( 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( // 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 { it.createdAt }.thenBy { it.messageId }.thenBy { it.localId ?: "" }) + .sortedWith(compareBy { 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( // 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 { it.createdAt }.thenBy { it.messageId }) + val sorted = + response.messages.sortedWith(compareBy { it.createdAt }.thenBy { it.messageId }) val chatItems = sorted.map { serverMsg -> val domain = serverMsg.toDomain() if (domain.mine) { @@ -547,10 +558,11 @@ class ChatRoomActivity : BaseActivity( 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 { it.createdAt }.thenBy { it.messageId }) + val sorted = + response.messages.sortedWith(compareBy { it.createdAt }.thenBy { it.messageId }) // 중복 제거: 기존 목록의 messageId 집합과 비교 val existingIds: Set = items.mapNotNull { @@ -599,6 +611,72 @@ class ChatRoomActivity : BaseActivity( // 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 = 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" diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ImageLoader.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ImageLoader.kt index 58887c72..7b8e8793 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ImageLoader.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ImageLoader.kt @@ -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) + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ServerChatMessage.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ServerChatMessage.kt index 04cac086..cef918da 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ServerChatMessage.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ServerChatMessage.kt @@ -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 ) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/db/ChatMessageDatabase.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/db/ChatMessageDatabase.kt index 032ba301..82b542a9 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/db/ChatMessageDatabase.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/db/ChatMessageDatabase.kt @@ -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 } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/db/ChatMessageEntity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/db/ChatMessageEntity.kt index 705bed8a..ae19ef88 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/db/ChatMessageEntity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/db/ChatMessageEntity.kt @@ -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 ) diff --git a/app/src/main/res/layout/item_chat_ai_message.xml b/app/src/main/res/layout/item_chat_ai_message.xml index 1803a24d..665561fd 100644 --- a/app/src/main/res/layout/item_chat_ai_message.xml +++ b/app/src/main/res/layout/item_chat_ai_message.xml @@ -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" /> + + + + + + + + + + + + + + + + + + + + + app:layout_constraintStart_toEndOf="@id/message_group" + app:layout_constraintEnd_toEndOf="@id/guideline_90" />