diff --git a/app/src/main/res/drawable/bg_chat_floating_button.xml b/app/src/main/res/drawable/bg_chat_floating_button.xml
new file mode 100644
index 00000000..e63886f2
--- /dev/null
+++ b/app/src/main/res/drawable/bg_chat_floating_button.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_v2_main_chat.xml b/app/src/main/res/layout/fragment_v2_main_chat.xml
index 7c7115b6..a777aea0 100644
--- a/app/src/main/res/layout/fragment_v2_main_chat.xml
+++ b/app/src/main/res/layout/fragment_v2_main_chat.xml
@@ -1,5 +1,52 @@
-
+ android:background="@color/black">
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/view_title_bar_default.xml b/app/src/main/res/layout/view_title_bar_default.xml
index 476d7d5f..214f5a61 100644
--- a/app/src/main/res/layout/view_title_bar_default.xml
+++ b/app/src/main/res/layout/view_title_bar_default.xml
@@ -24,9 +24,17 @@
android:layout_height="0dp"
android:layout_weight="1" />
-
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+
+
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 2c605606..1b6d1627 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -157,6 +157,7 @@
All
AI Chat
DM
+ New chat
On Air
View all
There are currently no live broadcasts available, or access may be restricted due to age limits.\nVerify your identity or follow the channel to get notified when a live broadcast starts.
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 557190a6..3e2dd9c7 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -157,6 +157,7 @@
すべて
AIチャット
DM
+ 新しいチャット
ただいま配信中
配信中のライブをすべて見る
参加可能なライブがないか、年齢制限で入室できません。\n本人確認を行うか、チャンネルをフォローして\n配信通知を受け取ってみましょう。
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ba86dc07..0921fd27 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -156,6 +156,7 @@
전체
AI 채팅
DM
+ 새 대화
지금 라이브 중
지금 라이브 중 전체보기
현재 참여 가능한 라이브 방송이 없거나\n연령제한으로 입장이 불가능합니다.\n본인인증을 해보거나 채널을 팔로잉하고\n라이브 방송 알림을 받아보세요.
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
new file mode 100644
index 00000000..e2d30866
--- /dev/null
+++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt
@@ -0,0 +1,110 @@
+package kr.co.vividnext.sodalive.v2.main.chat
+
+import android.app.Application
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.core.app.ApplicationProvider
+import kr.co.vividnext.sodalive.R
+import kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28], application = Application::class)
+class ChatMainFragmentLayoutTest {
+
+ @Test
+ fun `기본 title bar는 title과 가변 action container를 함께 제공한다`() {
+ val titleBar = inflateView(R.layout.view_title_bar_default) as LinearLayout
+ val title = requireNotNull(titleBar.findViewById(R.id.tv_title_bar_title))
+ val actions = requireNotNull(titleBar.findViewById(R.id.ll_title_bar_actions))
+ val menu = requireNotNull(titleBar.findViewById(R.id.iv_title_bar_menu))
+
+ assertSame(actions, menu.parent)
+ assertSame(titleBar, title.parent)
+ assertSame(titleBar, actions.parent)
+ assertTrue(titleBar.indexOfChild(title) < titleBar.indexOfChild(actions))
+ assertEquals(LinearLayout.HORIZONTAL, actions.orientation)
+ assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, actions.layoutParams.width)
+ assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, actions.layoutParams.height)
+ }
+
+ @Test
+ fun `채팅 fragment layout은 title bar tab list floating button만 포함한다`() {
+ val root = inflateView(R.layout.fragment_v2_main_chat) as ConstraintLayout
+ val titleBar = requireNotNull(root.findViewById(R.id.view_chat_title_bar))
+ val tabBar = requireNotNull(root.findViewById(R.id.view_chat_filter_tabs))
+ val recyclerView = requireNotNull(root.findViewById(R.id.rv_chat_rooms))
+ val floatingButton = requireNotNull(root.findViewById(R.id.btn_chat_floating))
+
+ assertEquals(Color.BLACK, (root.background as ColorDrawable).color)
+ assertSame(root, titleBar.parent)
+ assertSame(root, tabBar.parent)
+ assertSame(root, recyclerView.parent)
+ assertSame(root, floatingButton.parent)
+ assertFalse(root.containsClassName("com.google.android.material.bottomnavigation.BottomNavigationView"))
+ assertFalse(root.containsViewIdContaining("unread"))
+ }
+
+ @Test
+ fun `채팅 fragment list와 floating button은 계획된 constraint를 사용한다`() {
+ val root = inflateView(R.layout.fragment_v2_main_chat)
+ val titleBar = requireNotNull(root.findViewById(R.id.view_chat_title_bar))
+ val tabBar = requireNotNull(root.findViewById(R.id.view_chat_filter_tabs))
+ val recyclerView = requireNotNull(root.findViewById(R.id.rv_chat_rooms))
+ val floatingButton = requireNotNull(root.findViewById(R.id.btn_chat_floating))
+ val tabParams = tabBar.layoutParams as ConstraintLayout.LayoutParams
+ val listParams = recyclerView.layoutParams as ConstraintLayout.LayoutParams
+ val buttonParams = floatingButton.layoutParams as ConstraintLayout.LayoutParams
+
+ assertEquals(60.dpToPx(), titleBar.layoutParams.height)
+ assertEquals(52.dpToPx(), tabBar.layoutParams.height)
+ assertEquals(R.id.view_chat_title_bar, tabParams.topToBottom)
+ assertEquals(R.id.view_chat_filter_tabs, listParams.topToBottom)
+ assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, listParams.bottomToBottom)
+ assertEquals(false, recyclerView.clipToPadding)
+ assertTrue(recyclerView.paddingBottom >= 28.dpToPx())
+ assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, buttonParams.endToEnd)
+ assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, buttonParams.bottomToBottom)
+ }
+
+ private fun inflateView(layoutResId: Int): View {
+ val context = ApplicationProvider.getApplicationContext()
+ return LayoutInflater.from(context).inflate(layoutResId, null, false)
+ }
+
+ private fun View.containsViewIdContaining(idNamePart: String): Boolean {
+ if (id != View.NO_ID) {
+ val idName = runCatching { resources.getResourceEntryName(id) }.getOrNull()
+ if (idName != null && idName.contains(idNamePart, ignoreCase = true)) return true
+ }
+ if (this !is ViewGroup) return false
+ return (0 until childCount).any { getChildAt(it).containsViewIdContaining(idNamePart) }
+ }
+
+ private fun View.containsClassName(className: String): Boolean {
+ if (javaClass.name == className) return true
+ if (this !is ViewGroup) return false
+ return (0 until childCount).any { getChildAt(it).containsClassName(className) }
+ }
+
+ private fun Int.dpToPx(): Int {
+ val context = ApplicationProvider.getApplicationContext()
+ return (this * context.resources.displayMetrics.density).toInt()
+ }
+}