feat(chat): DM 채팅방 Activity를 추가한다
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ActivityDmChatRoomBinding>"))
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user