feat(chat): 채팅 탭 화면 동작을 연결한다

This commit is contained in:
2026-06-10 15:06:35 +09:00
parent 516e4a94bf
commit b38e58af9a
2 changed files with 186 additions and 1 deletions

View File

@@ -1,8 +1,123 @@
package kr.co.vividnext.sodalive.v2.main.chat package kr.co.vividnext.sodalive.v2.main.chat
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import android.widget.Toast
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.databinding.FragmentV2MainChatBinding import kr.co.vividnext.sodalive.databinding.FragmentV2MainChatBinding
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiState
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomType
import kr.co.vividnext.sodalive.v2.main.chat.ui.ChatRoomListAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>( class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
FragmentV2MainChatBinding::inflate FragmentV2MainChatBinding::inflate
) ) {
companion object {
private const val PAGINATION_THRESHOLD = 3
}
private val viewModel: ChatMainViewModel by viewModel()
private val chatRoomListAdapter = ChatRoomListAdapter { onChatRoomClick(it) }
private lateinit var loadingDialog: LoadingDialog
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
setupTitleBar()
setupFilterTabs()
setupChatRooms()
bindViewModel()
binding.btnChatFloating.setOnClickListener { }
viewModel.loadFirstPage()
}
private fun setupTitleBar() {
binding.viewChatTitleBar.tvTitleBarTitle.setText(R.string.tab_chat)
binding.viewChatTitleBar.ivTitleBarMenu.setImageResource(R.drawable.ic_bar_cash)
binding.viewChatTitleBar.llTitleBarActions.addView(
ImageView(requireContext()).apply {
setImageResource(R.drawable.ic_bar_search)
}
)
}
private fun setupFilterTabs() {
binding.viewChatFilterTabs.root.setMenus(
listOf(
getString(R.string.screen_chat_filter_all),
getString(R.string.screen_chat_filter_ai),
getString(R.string.screen_chat_filter_dm)
),
selectedIndex = 0
)
binding.viewChatFilterTabs.root.setOnTabSelectedListener { index ->
viewModel.selectFilter(ChatRoomFilter.fromTabIndex(index))
binding.rvChatRooms.scrollToPosition(0)
}
}
private fun setupChatRooms() {
val layoutManager = LinearLayoutManager(requireContext())
binding.rvChatRooms.layoutManager = layoutManager
binding.rvChatRooms.adapter = chatRoomListAdapter
binding.rvChatRooms.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy <= 0) return
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
val shouldLoadNext = lastVisiblePosition >=
chatRoomListAdapter.itemCount - PAGINATION_THRESHOLD
if (shouldLoadNext) {
viewModel.loadNextPage()
}
}
})
}
private fun bindViewModel() {
viewModel.chatRoomStateLiveData.observe(viewLifecycleOwner) { state ->
when (state) {
is ChatRoomListUiState.Content -> {
chatRoomListAdapter.submitItems(state.items)
if (!state.isAppending) {
binding.rvChatRooms.scrollToPosition(0)
}
}
ChatRoomListUiState.Empty -> chatRoomListAdapter.submitItems(emptyList())
is ChatRoomListUiState.Error -> chatRoomListAdapter.submitItems(emptyList())
ChatRoomListUiState.Loading -> Unit
}
}
viewModel.isLoading.observe(viewLifecycleOwner) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(viewLifecycleOwner) {
val text = it?.message ?: it?.resId?.let { resId -> getString(resId) }
if (!text.isNullOrBlank()) {
Toast.makeText(requireContext(), text, Toast.LENGTH_LONG).show()
}
}
}
private fun onChatRoomClick(item: ChatRoomListUiItem) {
if (item.chatType != ChatRoomType.AI) return
startActivity(ChatRoomActivity.newIntent(requireContext(), item.roomId))
}
}

View File

@@ -23,6 +23,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class) @Config(sdk = [28], application = Application::class)
@@ -83,11 +84,80 @@ class ChatMainFragmentLayoutTest {
assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, buttonParams.bottomToBottom) assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, buttonParams.bottomToBottom)
} }
@Test
fun `채팅 fragment source는 화면 초기 구성과 첫 페이지 로드를 연결한다`() {
val source = chatMainFragmentSource()
assertTrue(source.contains("private val viewModel: ChatMainViewModel by viewModel()"))
assertTrue(source.contains("ChatRoomListAdapter"))
assertTrue(source.contains("LoadingDialog(requireActivity(), layoutInflater)"))
assertTrue(source.contains("tvTitleBarTitle.setText(R.string.tab_chat)"))
assertTrue(source.contains("ivTitleBarMenu.setImageResource(R.drawable.ic_bar_cash)"))
assertTrue(source.contains("llTitleBarActions.addView"))
assertTrue(source.contains("setImageResource(R.drawable.ic_bar_search)"))
assertTrue(source.contains("setMenus("))
assertTrue(source.contains("R.string.screen_chat_filter_all"))
assertTrue(source.contains("R.string.screen_chat_filter_ai"))
assertTrue(source.contains("R.string.screen_chat_filter_dm"))
assertTrue(source.contains("viewModel.loadFirstPage()"))
}
@Test
fun `채팅 fragment source는 filter list pagination observe를 연결한다`() {
val source = chatMainFragmentSource()
assertTrue(source.contains("setOnTabSelectedListener"))
assertTrue(source.contains("ChatRoomFilter.fromTabIndex(index)"))
assertTrue(source.contains("LinearLayoutManager(requireContext())"))
assertTrue(source.contains("rvChatRooms.adapter = chatRoomListAdapter"))
assertTrue(source.contains("addOnScrollListener(object : RecyclerView.OnScrollListener()"))
assertTrue(source.contains("findLastVisibleItemPosition()"))
assertTrue(source.contains("viewModel.loadNextPage()"))
assertTrue(source.contains("chatRoomStateLiveData.observe(viewLifecycleOwner)"))
assertTrue(source.contains("ChatRoomListUiState.Content"))
assertTrue(source.contains("ChatRoomListUiState.Empty"))
assertTrue(source.contains("ChatRoomListUiState.Error"))
assertTrue(source.contains("viewModel.isLoading.observe(viewLifecycleOwner)"))
assertTrue(source.contains("viewModel.toastLiveData.observe(viewLifecycleOwner)"))
}
@Test
fun `채팅 fragment source는 AI 항목만 채팅방으로 이동한다`() {
val source = chatMainFragmentSource()
assertTrue(source.contains("private fun onChatRoomClick(item: ChatRoomListUiItem)"))
assertTrue(source.contains("if (item.chatType != ChatRoomType.AI) return"))
assertTrue(source.contains("ChatRoomActivity.newIntent(requireContext(), item.roomId)"))
assertTrue(source.contains("btnChatFloating.setOnClickListener { }"))
}
@Test
fun `채팅 fragment source는 탭 전환 및 첫 페이지 로딩 시 스크롤을 최상단으로 이동시킨다`() {
val source = chatMainFragmentSource()
assertTrue(source.contains("setOnTabSelectedListener"))
assertTrue(source.contains("rvChatRooms.scrollToPosition(0)"))
assertTrue(source.contains("!state.isAppending"))
}
private fun inflateView(layoutResId: Int): View { private fun inflateView(layoutResId: Int): View {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
return LayoutInflater.from(context).inflate(layoutResId, null, false) return LayoutInflater.from(context).inflate(layoutResId, null, false)
} }
private fun chatMainFragmentSource(): String = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt"
).readText()
private fun projectFile(relativePath: String): File {
val candidates = listOf(
File(relativePath),
File("../$relativePath")
)
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
private fun View.containsViewIdContaining(idNamePart: String): Boolean { private fun View.containsViewIdContaining(idNamePart: String): Boolean {
if (id != View.NO_ID) { if (id != View.NO_ID) {
val idName = runCatching { resources.getResourceEntryName(id) }.getOrNull() val idName = runCatching { resources.getResourceEntryName(id) }.getOrNull()