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 be8ce5e8..13d5b2a7 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 @@ -1,12 +1,16 @@ package kr.co.vividnext.sodalive.chat.talk import io.reactivex.rxjava3.core.Single -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.ChatMessagePurchaseRequest 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.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.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 retrofit2.http.Body import retrofit2.http.GET @@ -14,7 +18,6 @@ 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") @@ -48,7 +51,7 @@ interface TalkApi { @Header("Authorization") authHeader: String, @Path("roomId") roomId: Long, @Body request: SendMessageRequest - ): Single>> + ): Single> // 점진적 메시지 로딩 API @GET("/api/chat/room/{roomId}/messages") @@ -67,4 +70,17 @@ interface TalkApi { @Path("messageId") messageId: Long, @Body request: ChatMessagePurchaseRequest ): Single> + + // 채팅 쿼터 상태 조회 + @GET("/api/chat/quota/me") + fun getChatQuotaStatus( + @Header("Authorization") authHeader: String + ): Single> + + // 채팅 쿼터 구매 + @POST("/api/chat/quota/purchase") + fun purchaseChatQuota( + @Header("Authorization") authHeader: String, + @Body request: ChatQuotaPurchaseRequest + ): Single> } 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 aae99ece..bb260a84 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 @@ -6,6 +6,7 @@ package kr.co.vividnext.sodalive.chat.talk.room import android.annotation.SuppressLint +import android.os.Bundle import android.text.TextUtils import android.view.LayoutInflater import android.view.View @@ -16,8 +17,8 @@ import android.widget.TextView import androidx.annotation.LayoutRes import androidx.annotation.VisibleForTesting import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.databinding.ItemChatAiMessageBinding import kr.co.vividnext.sodalive.databinding.ItemChatTypingIndicatorBinding @@ -30,6 +31,7 @@ sealed class ChatListItem { data class UserMessage(val data: ChatMessage) : ChatListItem() data class AiMessage(val data: ChatMessage, val displayName: String? = null) : ChatListItem() data class Notice(val text: String) : ChatListItem() + data class QuotaNotice(val timeText: String? = null) : ChatListItem() object TypingIndicator : ChatListItem() } @@ -56,6 +58,7 @@ class ChatMessageAdapter : RecyclerView.Adapter() { fun onRetrySend(localId: String) fun onPurchaseMessage(message: ChatMessage) fun onOpenPurchasedImage(message: ChatMessage) + fun onPurchaseQuota() } private var callback: Callback? = null @@ -69,13 +72,18 @@ class ChatMessageAdapter : RecyclerView.Adapter() { const val VIEW_TYPE_AI_MESSAGE = 2 const val VIEW_TYPE_NOTICE = 3 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]을 기준으로 그룹화 여부와 해당 아이템이 그룹의 마지막인지 계산한다. * 테스트를 위해 노출된 유틸 함수이며, onBindViewHolder의 로직과 동일한 판정을 수행한다. */ @VisibleForTesting - internal fun computeGroupingFlags(list: List, position: Int): Pair { + internal fun computeGroupingFlags( + list: List, + position: Int + ): Pair { fun isSameSender(prev: ChatListItem?, curr: ChatListItem?): Boolean { if (prev == null || curr == null) return false val p = when (prev) { @@ -90,6 +98,7 @@ class ChatMessageAdapter : RecyclerView.Adapter() { } return p == c } + val curr = list.getOrNull(position) val prev = if (position > 0) 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() { is ChatListItem.UserMessage -> { val data = item.data 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 -> { val data = item.data 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.QuotaNotice -> ("quota_notice").hashCode().toLong() is ChatListItem.TypingIndicator -> Long.MIN_VALUE // 고정 ID } } @@ -180,11 +194,24 @@ class ChatMessageAdapter : RecyclerView.Adapter() { // 안정 ID 기준 비교와 동일한 로직 적용 return getStableIdFor(o) == getStableIdFor(n) } + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { val o = old[oldItemPosition] val n = newItems[newItemPosition] 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.addAll(newItems) @@ -220,6 +247,7 @@ class ChatMessageAdapter : RecyclerView.Adapter() { is ChatListItem.AiMessage -> item.data.mine else -> null } + val p = mineOf(prev) val c = mineOf(curr) return (p != null && c != null && p == c) @@ -237,6 +265,7 @@ class ChatMessageAdapter : RecyclerView.Adapter() { is ChatListItem.AiMessage -> item.data.mine else -> null } + val c = mineOf(curr) val n = mineOf(next) return (c != null && n != null && c == n) @@ -248,6 +277,7 @@ class ChatMessageAdapter : RecyclerView.Adapter() { is ChatListItem.AiMessage -> VIEW_TYPE_AI_MESSAGE is ChatListItem.Notice -> VIEW_TYPE_NOTICE is ChatListItem.TypingIndicator -> VIEW_TYPE_TYPING_INDICATOR + is ChatListItem.QuotaNotice -> VIEW_TYPE_QUOTA_NOTICE } } @@ -286,6 +316,16 @@ class ChatMessageAdapter : RecyclerView.Adapter() { 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") } } @@ -340,9 +380,33 @@ class ChatMessageAdapter : RecyclerView.Adapter() { is TypingIndicatorViewHolder -> { 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 + ) { + 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 /** 사용자 메시지 뷰홀더: 시간 포맷팅, 상태(투명도) 표시 */ @@ -352,7 +416,8 @@ class ChatMessageAdapter : RecyclerView.Adapter() { fun bind(data: ChatMessage, showTime: Boolean, isGrouped: Boolean) { binding.tvMessage.text = data.message // 화면 너비의 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.visibility = if (showTime) View.VISIBLE else View.GONE @@ -478,7 +543,8 @@ class ChatMessageAdapter : RecyclerView.Adapter() { 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() + binding.tvMessage.maxWidth = + (itemView.resources.displayMetrics.widthPixels * 0.90f).toInt() } // 그룹 내부 간격 최소화 (상단 패딩 축소) @@ -575,6 +641,28 @@ class ChatMessageAdapter : RecyclerView.Adapter() { // 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 private fun inflateAndroidSimpleItem( parent: ViewGroup, @@ -617,6 +705,7 @@ class ChatMessageAdapter : RecyclerView.Adapter() { // 클릭 리스너 제거로 누수 및 중복 클릭 방지 holder.itemView.findViewById(R.id.iv_retry)?.setOnClickListener(null) } + is AiMessageViewHolder -> { // 이미지 태그/리소스 정리로 잘못된 재활용 방지 holder.itemView.findViewById(R.id.iv_profile)?.let { v -> 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 cd69a56c..1fc23b48 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 @@ -12,6 +12,8 @@ import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers 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.quota.ChatQuotaPurchaseRequest +import kr.co.vividnext.sodalive.chat.talk.room.quota.ChatQuotaStatusResponse import kr.co.vividnext.sodalive.common.ApiResponse import java.util.concurrent.Callable @@ -30,7 +32,7 @@ class ChatRepository( roomId: Long, localId: String, content: String - ): Single> { + ): Single { return talkApi.sendMessage( authHeader = token, roomId = roomId, @@ -38,21 +40,36 @@ class ChatRepository( ) .subscribeOn(Schedulers.io()) .map { ensureSuccess(it) } - .flatMap { serverMsgs -> + .flatMap { response -> // 1) 로컬에 사용자 메시지 상태를 SENT로 업데이트 val updateStatus = Completable.fromAction { kotlinx.coroutines.runBlocking { chatDao.updateStatusByLocalId(roomId, localId, MessageStatus.SENT.name) } } // 2) 서버 응답 메시지들을 로컬 DB에 저장(중복 방지: 동일 ID는 REPLACE) 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) } } updateStatus.andThen(insertServers) - .andThen(Single.just(serverMsgs)) + .andThen(Single.just(response)) .subscribeOn(Schedulers.io()) } } + + /** 쿼터 상태 조회 */ + fun getChatQuotaStatus(token: String): Single { + return talkApi.getChatQuotaStatus(authHeader = token) + .subscribeOn(Schedulers.io()) + .map { ensureSuccess(it) } + } + + /** 쿼터 구매 */ + fun purchaseChatQuota(token: String): Single { + return talkApi.purchaseChatQuota(authHeader = token, request = ChatQuotaPurchaseRequest()) + .subscribeOn(Schedulers.io()) + .map { ensureSuccess(it) } + } + /** * 로컬에서 최근 20개 메시지 조회 */ 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 ad8c8a7c..024e9c79 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 @@ -13,6 +13,7 @@ import androidx.core.content.edit import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SimpleItemAnimator import coil.load import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers 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.moneyFormat import org.koin.android.ext.android.inject +import java.util.Locale class ChatRoomActivity : BaseActivity( ActivityChatRoomBinding::inflate ) { + private var roomId: Long = 0L private lateinit var chatAdapter: ChatMessageAdapter private lateinit var layoutManager: LinearLayoutManager @@ -41,6 +44,9 @@ class ChatRoomActivity : BaseActivity( private var hasMoreMessages: Boolean = true // Repository 연동 시 서버 값으로 갱신 예정 private var nextCursor: Long? = null // 가장 오래된 메시지의 timestamp 등 + // 쿼터/카운트다운 상태 + private var quotaTimer: android.os.CountDownTimer? = null + // 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관) private var characterInfo: CharacterInfo? = null @@ -80,7 +86,8 @@ class ChatRoomActivity : BaseActivity( // 더보기 클릭 시 전체화면 다이얼로그 표시 binding.ivMore.setOnClickListener { - ChatRoomMoreDialogFragment.newInstance(roomId).show(supportFragmentManager, "ChatRoomMoreDialog") + ChatRoomMoreDialogFragment.newInstance(roomId) + .show(supportFragmentManager, "ChatRoomMoreDialog") } // 5.3: characterInfo가 있으면 헤더 바인딩, 없으면 기본 플레이스홀더 유지 @@ -184,10 +191,17 @@ class ChatRoomActivity : BaseActivity( override fun onOpenPurchasedImage(message: ChatMessage) { openPurchasedImageCarousel(message) } + + override fun onPurchaseQuota() { + onPurchaseQuotaClicked() + } }) } binding.rvMessages.adapter = chatAdapter + // Change 애니메이션 비활성화로 매초 부분 갱신 시 깜빡임 최소화 + (binding.rvMessages.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false + // 현재 보유 중인 캐릭터 프로필/이름을 타이핑 인디케이터에도 반영 chatAdapter.setTypingInfo( characterInfo?.name, @@ -348,18 +362,21 @@ class ChatRoomActivity : BaseActivity( content = content ) .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ serverMsgs -> + .subscribe({ response -> // 성공: 타이핑 인디케이터 제거 및 상태 업데이트 chatAdapter.hideTypingIndicator() updateUserMessageStatus(localId, MessageStatus.SENT) // 서버 응답이 여러 개인 경우, mine == false(AI)만 순서대로 추가 - serverMsgs.forEach { msg -> + response.messages.forEach { msg -> val domain = msg.toDomain() if (!domain.mine) { appendMessage(ChatListItem.AiMessage(domain, characterInfo?.name)) } } + + // 응답에 포함된 쿼터 상태로 UI 갱신 + updateQuotaUi(response.totalRemaining, response.nextRechargeAtEpoch) }, { error -> // 실패: 타이핑 인디케이터 제거 및 FAILED로 업데이트 chatAdapter.hideTypingIndicator() @@ -438,6 +455,133 @@ class ChatRoomActivity : BaseActivity( } // 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() { // 7.1 보완: 로컬 우선 표시 + 서버 동기화 isLoading = true @@ -494,6 +638,9 @@ class ChatRoomActivity : BaseActivity( scrollToBottom() isLoading = false + // 쿼터 UI 갱신 + updateQuotaUi(response.totalRemaining, response.nextRechargeAtEpoch) + // 7.3: 오래된 메시지 정리(백그라운드) compositeDisposable.add( chatRepository.trimOldMessages(roomId, keepLatest = 200) @@ -721,6 +868,7 @@ class ChatRoomActivity : BaseActivity( } companion object { + private const val DISPLAY_FUDGE_MS: Long = 5_000L const val EXTRA_ROOM_ID: String = "extra_room_id" fun newIntent(context: Context, roomId: Long): Intent { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomEnterResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomEnterResponse.kt index 191d18de..0630e772 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomEnterResponse.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomEnterResponse.kt @@ -11,5 +11,7 @@ data class ChatRoomEnterResponse( @SerializedName("roomId") val roomId: Long, @SerializedName("character") val character: CharacterInfo, @SerializedName("messages") val messages: List, - @SerializedName("hasMoreMessages") val hasMoreMessages: Boolean + @SerializedName("hasMoreMessages") val hasMoreMessages: Boolean, + @SerializedName("totalRemaining") val totalRemaining: Int = 0, + @SerializedName("nextRechargeAtEpoch") val nextRechargeAtEpoch: Long? = null ) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/SendChatMessageResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/SendChatMessageResponse.kt new file mode 100644 index 00000000..54fbddb0 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/SendChatMessageResponse.kt @@ -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, + @SerializedName("totalRemaining") val totalRemaining: Int = 0, + @SerializedName("nextRechargeAtEpoch") val nextRechargeAtEpoch: Long? = null +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/quota/ChatQuotaPurchaseRequest.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/quota/ChatQuotaPurchaseRequest.kt new file mode 100644 index 00000000..3820c8e2 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/quota/ChatQuotaPurchaseRequest.kt @@ -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" +) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/quota/ChatQuotaStatusResponse.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/quota/ChatQuotaStatusResponse.kt new file mode 100644 index 00000000..03f94690 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/quota/ChatQuotaStatusResponse.kt @@ -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? +) diff --git a/app/src/main/res/drawable-mdpi/ic_time.png b/app/src/main/res/drawable-mdpi/ic_time.png new file mode 100644 index 00000000..150da3cd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_time.png differ diff --git a/app/src/main/res/drawable/bg_chat_notice_quota.xml b/app/src/main/res/drawable/bg_chat_notice_quota.xml new file mode 100644 index 00000000..15a62003 --- /dev/null +++ b/app/src/main/res/drawable/bg_chat_notice_quota.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/item_chat_quota_notice.xml b/app/src/main/res/layout/item_chat_quota_notice.xml new file mode 100644 index 00000000..321239ab --- /dev/null +++ b/app/src/main/res/layout/item_chat_quota_notice.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + +