From 590a52c60523e374ff4dbb31dc0efb10320e14b2 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 11 Jun 2026 11:17:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20DM=20=EC=B1=84=ED=8C=85=EB=B0=A9?= =?UTF-8?q?=20Activity=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/main/chat/dm/DmChatRoomActivity.kt | 208 ++++++++++++++++++ .../chat/dm/DmChatRoomActivitySourceTest.kt | 61 +++++ 2 files changed, 269 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt new file mode 100644 index 00000000..acba571c --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt @@ -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::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) + } + } + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt new file mode 100644 index 00000000..8eb089ed --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt @@ -0,0 +1,61 @@ +package kr.co.vividnext.sodalive.v2.main.chat.dm + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class DmChatRoomActivitySourceTest { + + @Test + fun `DM 채팅방 Activity source는 intent binding input pagination scroll lifecycle을 연결한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt" + ).readText() + + assertTrue(source.contains("BaseActivity")) + assertTrue(source.contains("const val EXTRA_ROOM_ID")) + assertTrue(source.contains("const val EXTRA_CREATOR_ID")) + assertTrue(source.contains("fun newIntentByRoomId")) + assertTrue(source.contains("fun newIntentByCreatorId")) + assertTrue(source.contains("private val viewModel: DmChatRoomViewModel by viewModel()")) + assertTrue(source.contains("viewModel.enter(roomId = roomId, creatorId = creatorId)")) + assertTrue(source.contains("binding.ivBack.setOnClickListener { finish() }")) + assertTrue(source.contains("DmChatMessageAdapter")) + assertTrue(source.contains("binding.etMessage.setOnEditorActionListener")) + assertTrue(source.contains("EditorInfo.IME_ACTION_SEND")) + assertTrue(source.contains("binding.ivSend.setOnClickListener")) + assertTrue(source.contains("viewModel.sendText")) + assertTrue(source.contains("findFirstVisibleItemPosition()")) + assertTrue(source.contains("viewModel.loadOlderMessages()")) + assertTrue(source.contains("prependedMessageCountLiveData.observe(this)")) + assertTrue(source.contains("scrollToPositionWithOffset")) + assertTrue(source.contains("isNearBottom()")) + assertTrue(source.contains("private var isStarted: Boolean = false")) + assertTrue(source.contains("viewModel.connectRealtime()")) + assertTrue(source.contains("roomOpenedEventLiveData.observe(this)")) + assertTrue(source.contains("if (it.consume() == true) connectRealtimeIfStarted()")) + assertTrue(source.contains("connectRealtimeIfStarted()")) + assertTrue(source.contains("viewModel.disconnectRealtime()")) + assertFalse(source.contains("if (isStarted) viewModel.connectRealtime()")) + assertTrue(!source.contains("character_type_badge")) + assertTrue(!source.contains("notice_container")) + } + + @Test + fun `DM 채팅방 Activity intent helper는 roomId와 creatorId를 각각 전달한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt" + ).readText() + + assertTrue(source.contains("Intent(context, DmChatRoomActivity::class.java)")) + assertTrue(source.contains("putExtra(EXTRA_ROOM_ID, roomId)")) + assertTrue(source.contains("putExtra(EXTRA_CREATOR_ID, creatorId)")) + } + + private fun projectFile(relativePath: String): File { + val candidates = listOf(File(relativePath), File("../$relativePath")) + return candidates.firstOrNull { it.exists() } + ?: error("Project file not found: $relativePath") + } +}