feat(chat): 대화 탭 DM 시작 진입점을 추가한다

This commit is contained in:
2026-09-14 22:05:12 +09:00
parent 127e5fc147
commit 4f6b235d60
4 changed files with 184 additions and 0 deletions
@@ -12,6 +12,9 @@ import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseFragment
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerActivity
import kr.co.vividnext.sodalive.databinding.FragmentV2MainChatBinding
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity
@@ -51,12 +54,19 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
private var isChatPullRefreshing = false
private var refreshingChatFilter: ChatRoomFilter? = null
private var lastRenderedChatContentState: ChatRoomListUiState.Content? = null
private var shouldRefreshChatRoomsOnResume = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
shouldRefreshChatRoomsOnResume = false
loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
setupTitleBar()
val isCreator = SharedPreferenceManager.role == MemberRole.CREATOR.name
binding.btnNewDm.isVisible = isCreator
binding.btnNewDm.setOnClickListener {
if (isCreator) openWithLoginGuard(DmRecipientPickerActivity::class.java)
}
val initialFilter = getInitialFilter() ?: ChatRoomFilter.ALL
selectedFilter = initialFilter
setupFilterTabs(initialFilter)
@@ -67,6 +77,17 @@ class ChatMainFragment : BaseFragment<FragmentV2MainChatBinding>(
viewModel.loadFirstPage(initialFilter)
}
override fun onResume() {
super.onResume()
if (!shouldRefreshChatRoomsOnResume) {
shouldRefreshChatRoomsOnResume = true
return
}
cancelChatPullRefresh()
bindChatRooms(emptyList(), showEmpty = false)
viewModel.loadFirstPage(selectedFilter)
}
fun selectFilter(filter: ChatRoomFilter) {
if (refreshingChatFilter != null && refreshingChatFilter != filter) {
cancelChatPullRefresh()
@@ -58,4 +58,17 @@
tools:text="아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요." />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ImageButton
android:id="@+id/btn_new_dm"
android:layout_width="66dp"
android:layout_height="66dp"
android:layout_margin="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_owner_fab"
android:contentDescription="@string/screen_chat_floating_button"
android:elevation="8dp"
android:padding="@dimen/spacing_14"
android:src="@drawable/ic_plus_no_bg"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -110,6 +110,28 @@ class ChatMainFragmentLayoutTest {
assertTrue(source.contains("binding.swipeChatRooms.isRefreshing = false"))
}
@Test
fun `채팅방에서 복귀하면 현재 filter 첫 페이지로 목록을 갱신한다`() {
val source = chatMainFragmentSource()
val onResume = source.substringBetween(
"override fun onResume()",
"private fun setupTitleBar()"
)
assertTrue(source.contains("private var shouldRefreshChatRoomsOnResume = false"))
val onViewCreated = source.substringBetween(
"override fun onViewCreated",
"viewModel.loadFirstPage(initialFilter)"
)
assertTrue(onViewCreated.contains("shouldRefreshChatRoomsOnResume = false"))
assertTrue(onResume.contains("super.onResume()"))
assertTrue(onResume.contains("if (!shouldRefreshChatRoomsOnResume)"))
assertTrue(onResume.contains("shouldRefreshChatRoomsOnResume = true"))
assertTrue(onResume.contains("cancelChatPullRefresh()"))
assertTrue(onResume.contains("bindChatRooms(emptyList(), showEmpty = false)"))
assertTrue(onResume.contains("viewModel.loadFirstPage(selectedFilter)"))
}
@Test
fun `채팅 empty 상태에서 pull refresh loading은 empty message를 숨기지 않는다`() {
val source = chatMainFragmentSource()
@@ -0,0 +1,128 @@
package kr.co.vividnext.sodalive.v2.main.chat.recipient
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
class DmRecipientPickerSourceTest {
@Test
fun `대화 탭은 크리에이터에게만 수신자 선택 진입을 연결한다`() {
val source = source("main/chat/ChatMainFragment.kt")
assertTrue(source.contains("SharedPreferenceManager.role == MemberRole.CREATOR.name"))
assertTrue(source.contains("binding.btnNewDm.isVisible = isCreator"))
assertTrue(source.contains("if (isCreator)"))
assertTrue(source.contains("openWithLoginGuard(DmRecipientPickerActivity::class.java)"))
}
@Test
fun `수신자 화면은 팔로워와 검색 및 빈 결과 상태를 연결한다`() {
val source = source("main/chat/recipient/DmRecipientPickerActivity.kt")
assertTrue(source.contains("viewModel.enter()"))
assertTrue(source.contains("viewModel.onSearchQueryChanged"))
assertTrue(source.contains("DmRecipientPickerMode.FOLLOWERS"))
assertTrue(source.contains("state.searchResultCount"))
assertTrue(source.contains("state.isSearchEmpty"))
assertTrue(source.contains("adapter.submitItems(state.recipients)"))
val factory = source("main/chat/recipient/DmRecipientPickerFactory.kt")
assertTrue(factory.contains("getFollowerList("))
assertTrue(factory.contains("SharedPreferenceManager.userId"))
assertTrue(factory.contains("searchUser("))
assertTrue(factory.contains("observeOn(AndroidSchedulers.mainThread())"))
}
@Test
fun `수신자 선택은 공통 확인 팝업에서만 요청하고 취소는 부작용이 없다`() {
val source = source("main/chat/recipient/DmRecipientPickerActivity.kt")
val confirmation = source.substringAfter("private fun confirmRecipient(").substringBefore("private fun openRoom(")
assertTrue(confirmation.contains("V2ModalDialog("))
assertTrue(confirmation.contains("R.string.dm_recipient_confirm_title"))
assertTrue(confirmation.contains("getString(R.string.dm_recipient_confirm_message, recipient.nickname)"))
assertTrue(confirmation.contains("R.string.cancel"))
assertTrue(confirmation.contains("R.string.dm_recipient_send"))
assertTrue(confirmation.contains("confirmButtonClick = { openRoom(recipient.id) }"))
assertFalse(confirmation.contains("cancelButtonClick"))
assertFalse(confirmation.contains("createOrGetRoom"))
}
@Test
fun `보내기는 recipient 경로로 생성한 방 아이디로 기존 DM 화면을 연다`() {
val source = source("main/chat/recipient/DmRecipientPickerActivity.kt")
assertTrue(source.contains("createOrGetRoomByRecipientId(SharedPreferenceManager.token, recipientId)"))
assertTrue(source.contains("response.success && data != null"))
assertTrue(source.contains("DmChatRoomActivity.newIntentByRoomId(this, data.roomId)"))
assertFalse(source.contains("newIntentByCreatorId"))
assertTrue(
file("app/src/main/AndroidManifest.xml").readText().contains(
"kr.co.vividnext.sodalive.v2.main.chat.recipient.DmRecipientPickerActivity"
)
)
}
@Test
fun `수신자 화면 layout은 Figma 검색 toolbar와 검색결과 count를 사용한다`() {
val layout = file("app/src/main/res/layout/activity_dm_recipient_picker.xml").readText()
assertFalse(layout.contains("layout=\"@layout/detail_toolbar\""))
assertTrue(layout.contains("android:id=\"@+id/btn_back\""))
assertTrue(layout.contains("android:id=\"@+id/search_container\""))
assertTrue(layout.contains("android:id=\"@+id/iv_search\""))
assertTrue(layout.contains("android:id=\"@+id/et_search_nickname\""))
assertTrue(layout.contains("android:id=\"@+id/tv_search_result_count\""))
assertTrue(layout.contains("@string/dm_recipient_search_result_count"))
}
@Test
fun `검색 empty 문구는 목록 콘텐츠 영역에서 전환된다`() {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(file("app/src/main/res/layout/activity_dm_recipient_picker.xml"))
val content = requireNotNull(document.findById("recipient_content_container"))
val empty = requireNotNull(document.findById("tv_search_empty"))
val list = requireNotNull(document.findById("rv_recipients"))
assertSame(content, empty.parentNode)
assertSame(content, list.parentNode)
}
@Test
fun `새 수신자 문구는 지원 언어 전체에 정의된다`() {
val names = listOf(
"dm_recipient_search_hint",
"dm_recipient_followers",
"dm_recipient_search_result_count",
"dm_recipient_search_empty",
"dm_recipient_confirm_title",
"dm_recipient_confirm_message",
"dm_recipient_send"
)
for (directory in listOf("values", "values-en", "values-ja")) {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder()
.parse(file("app/src/main/res/$directory/strings.xml"))
val strings = document.getElementsByTagName("string")
val available = (0 until strings.length).map {
strings.item(it).attributes.getNamedItem("name").nodeValue
}
names.forEach { assertTrue("$directory/$it", it in available) }
}
}
private fun source(path: String): String {
val file = file("app/src/main/java/kr/co/vividnext/sodalive/v2/$path")
assertTrue("수신자 연결 소스가 필요함: $path", file.exists())
return file.readText()
}
private fun file(path: String): File =
if (File("app").isDirectory) File(path) else File("../$path")
private fun org.w3c.dom.Document.findById(id: String): org.w3c.dom.Node? {
val nodes = getElementsByTagName("*")
return (0 until nodes.length).map { nodes.item(it) }
.firstOrNull { node ->
val value = node.attributes?.getNamedItem("android:id")?.nodeValue
value == "@+id/$id" || value == "@id/$id"
}
}
}