diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt index eedf893f..8037c243 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt @@ -1,8 +1,123 @@ 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.chat.talk.room.ChatRoomActivity +import kr.co.vividnext.sodalive.common.LoadingDialog 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::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)) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt index e2d30866..30bf3d8f 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt @@ -23,6 +23,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.io.File @RunWith(RobolectricTestRunner::class) @Config(sdk = [28], application = Application::class) @@ -83,11 +84,80 @@ class ChatMainFragmentLayoutTest { 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 { val context = ApplicationProvider.getApplicationContext() 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 { if (id != View.NO_ID) { val idName = runCatching { resources.getResourceEntryName(id) }.getOrNull()