feat(chat): DM 채팅방 Activity를 추가한다

This commit is contained in:
2026-06-11 11:17:04 +09:00
parent f2687b8243
commit 590a52c605
2 changed files with 269 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm
import android.content.Context
import android.content.Intent
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import coil.transform.CircleCropTransformation
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.databinding.ActivityDmChatRoomBinding
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
import kr.co.vividnext.sodalive.v2.main.chat.dm.ui.DmChatMessageAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
class DmChatRoomActivity : BaseActivity<ActivityDmChatRoomBinding>(
ActivityDmChatRoomBinding::inflate
) {
private val viewModel: DmChatRoomViewModel by viewModel()
private val messageAdapter = DmChatMessageAdapter { localId -> viewModel.retry(localId) }
private lateinit var layoutManager: LinearLayoutManager
private var roomId: Long = 0L
private var creatorId: Long = 0L
private var isStarted: Boolean = false
private var firstVisiblePositionBeforePrepend: Int = RecyclerView.NO_POSITION
private var firstVisibleTopBeforePrepend: Int = 0
override fun setupView() {
roomId = intent.getLongExtra(EXTRA_ROOM_ID, 0L)
creatorId = intent.getLongExtra(EXTRA_CREATOR_ID, 0L)
setupHeader()
setupRecyclerView()
setupInput()
bindViewModel()
viewModel.enter(roomId = roomId, creatorId = creatorId)
}
override fun onStart() {
super.onStart()
isStarted = true
viewModel.connectRealtime()
}
override fun onStop() {
isStarted = false
viewModel.disconnectRealtime()
super.onStop()
}
private fun setupHeader() {
binding.ivBack.setOnClickListener { finish() }
binding.ivProfile.setImageResource(R.drawable.ic_placeholder_profile)
binding.ivBackgroundProfile.setImageResource(R.drawable.ic_placeholder_profile)
}
private fun setupRecyclerView() {
layoutManager = LinearLayoutManager(this).apply {
stackFromEnd = true
}
binding.rvMessages.layoutManager = layoutManager
binding.rvMessages.adapter = messageAdapter
(binding.rvMessages.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
binding.rvMessages.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (layoutManager.findFirstVisibleItemPosition() == 0) {
rememberScrollBeforePrepend()
viewModel.loadOlderMessages()
}
}
})
}
private fun setupInput() {
setSendButtonEnabled(false)
binding.etMessage.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
setSendButtonEnabled(!s.isNullOrBlank())
}
override fun afterTextChanged(s: Editable?) = Unit
})
binding.etMessage.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) {
if (binding.ivSend.isEnabled) binding.ivSend.performClick()
true
} else {
false
}
}
binding.ivSend.setOnClickListener { onSendClicked() }
}
private fun bindViewModel() {
viewModel.chatRoomStateLiveData.observe(this) { state ->
when (state) {
is DmChatRoomUiState.Content -> bindContent(state)
is DmChatRoomUiState.Error -> showToast(state.message.orEmpty())
DmChatRoomUiState.Loading -> Unit
}
}
viewModel.finishEventLiveData.observe(this) {
if (it == true) finish()
}
viewModel.prependedMessageCountLiveData.observe(this) { count ->
if (count > 0) restoreScrollAfterPrepend(count)
}
viewModel.roomOpenedEventLiveData.observe(this) {
if (it.consume() == true) connectRealtimeIfStarted()
}
}
private fun bindContent(state: DmChatRoomUiState.Content) {
val shouldScrollToBottom = isNearBottom()
binding.tvName.text = state.opponentNickname
binding.ivProfile.loadUrl(state.opponentProfileImageUrl) {
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
transformations(CircleCropTransformation())
}
binding.ivBackgroundProfile.loadUrl(state.opponentProfileImageUrl) {
placeholder(R.drawable.ic_placeholder_profile)
error(R.drawable.ic_placeholder_profile)
}
messageAdapter.submitItems(state.messages)
if (shouldScrollToBottom && state.messages.isNotEmpty()) {
binding.rvMessages.scrollToPosition(state.messages.lastIndex)
}
}
private fun onSendClicked() {
val text = binding.etMessage.text?.toString().orEmpty()
if (text.isBlank()) return
viewModel.sendText(text)
binding.etMessage.setText("")
setSendButtonEnabled(false)
hideKeyboard(binding.etMessage)
}
private fun setSendButtonEnabled(enabled: Boolean) {
binding.ivSend.isEnabled = enabled
binding.ivSend.alpha = if (enabled) 1.0f else 0.4f
}
private fun rememberScrollBeforePrepend() {
firstVisiblePositionBeforePrepend = layoutManager.findFirstVisibleItemPosition()
val firstVisibleView = layoutManager.findViewByPosition(firstVisiblePositionBeforePrepend)
firstVisibleTopBeforePrepend = firstVisibleView?.top ?: 0
}
private fun restoreScrollAfterPrepend(count: Int) {
if (firstVisiblePositionBeforePrepend == RecyclerView.NO_POSITION) return
layoutManager.scrollToPositionWithOffset(
firstVisiblePositionBeforePrepend + count,
firstVisibleTopBeforePrepend
)
firstVisiblePositionBeforePrepend = RecyclerView.NO_POSITION
firstVisibleTopBeforePrepend = 0
}
private fun isNearBottom(): Boolean {
val itemCount = messageAdapter.itemCount
if (itemCount == 0) return true
val lastVisible = layoutManager.findLastVisibleItemPosition()
return lastVisible >= itemCount - NEAR_BOTTOM_THRESHOLD
}
private fun connectRealtimeIfStarted() {
if (!isStarted) return
viewModel.connectRealtime()
}
private fun hideKeyboard(view: View) {
val imm = getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.hideSoftInputFromWindow(view.windowToken, 0)
view.clearFocus()
}
companion object {
const val EXTRA_ROOM_ID: String = "extra_room_id"
const val EXTRA_CREATOR_ID: String = "extra_creator_id"
private const val NEAR_BOTTOM_THRESHOLD = 2
fun newIntentByRoomId(context: Context, roomId: Long): Intent {
return Intent(context, DmChatRoomActivity::class.java).apply {
putExtra(EXTRA_ROOM_ID, roomId)
}
}
fun newIntentByCreatorId(context: Context, creatorId: Long): Intent {
return Intent(context, DmChatRoomActivity::class.java).apply {
putExtra(EXTRA_CREATOR_ID, creatorId)
}
}
}
}