feat(creator): 채널 홈 탭 전환을 연결한다

This commit is contained in:
2026-06-16 14:35:39 +09:00
parent 984aa13edf
commit f3c19ed8ba
5 changed files with 245 additions and 171 deletions

View File

@@ -3,11 +3,9 @@ package kr.co.vividnext.sodalive.v2.creator.channel
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.Typeface
import android.view.Gravity
import android.view.View import android.view.View
import android.view.View.MeasureSpec
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@@ -15,7 +13,9 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
@@ -31,25 +31,22 @@ import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHomeUiState
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelScrollState import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelScrollState
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab
import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTitleBarState import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTitleBarState
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelHomeSectionAdapter
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity
import org.koin.androidx.viewmodel.ext.android.viewModel
class CreatorChannelActivity : BaseActivity<ActivityCreatorChannelBinding>( class CreatorChannelActivity :
ActivityCreatorChannelBinding::inflate BaseActivity<ActivityCreatorChannelBinding>(ActivityCreatorChannelBinding::inflate),
) { CreatorChannelHomeFragment.Host {
private val viewModel: CreatorChannelHomeViewModel by viewModel()
private val sectionAdapter = CreatorChannelHomeSectionAdapter(::onScheduleClicked, ::onAudioContentClicked)
private var creatorId: Long = 0L private var creatorId: Long = 0L
private var currentHeader: CreatorChannelHeaderUiModel? = null private var currentHeader: CreatorChannelHeaderUiModel? = null
private var selectedTab: CreatorChannelTab = CreatorChannelTab.Home private var homeActionDelegate: CreatorChannelHomeFragment.HomeActionDelegate? = null
private var isFollowInProgress: Boolean = false private var isFollowInProgress: Boolean = false
private var statusBarHeight: Int = 0 private var statusBarHeight: Int = 0
private var tabLayoutMediator: TabLayoutMediator? = null
private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null
private val baseTitleBarHeight: Int by lazy { 60.dpToPx().toInt() } private val baseTitleBarHeight: Int by lazy { 60.dpToPx().toInt() }
override val shouldApplySystemBarTopInset: Boolean = false override val shouldApplySystemBarTopInset: Boolean = false
@@ -61,18 +58,11 @@ class CreatorChannelActivity : BaseActivity<ActivityCreatorChannelBinding>(
return return
} }
setupRecyclerView() setupTabsAndPager()
setStatusBarIconAppearance() setStatusBarIconAppearance()
setTitleBarTopInset() setTitleBarTopInset()
setupScrollListener() setupScrollListener()
setupClickListeners() setupClickListeners()
observeViewModel()
viewModel.loadHome(creatorId)
}
private fun setupRecyclerView() {
binding.rvHomeSections.layoutManager = LinearLayoutManager(this)
binding.rvHomeSections.adapter = sectionAdapter
} }
private fun setupClickListeners() { private fun setupClickListeners() {
@@ -81,47 +71,13 @@ class CreatorChannelActivity : BaseActivity<ActivityCreatorChannelBinding>(
binding.layoutFollowCapsule.setOnClickListener { onFollowActionClicked() } binding.layoutFollowCapsule.setOnClickListener { onFollowActionClicked() }
binding.ivBell.setOnClickListener { onFollowActionClicked() } binding.ivBell.setOnClickListener { onFollowActionClicked() }
binding.tvChatButton.setOnClickListener { binding.tvChatButton.setOnClickListener {
currentHeader?.characterId?.let { characterId -> viewModel.createChatRoom(characterId) } currentHeader?.characterId?.let { characterId -> homeActionDelegate?.createChatRoom(characterId) }
} }
binding.tvDmButton.setOnClickListener { binding.tvDmButton.setOnClickListener {
startActivity(DmChatRoomActivity.newIntentByCreatorId(this, creatorId)) startActivity(DmChatRoomActivity.newIntentByCreatorId(this, creatorId))
} }
} }
private fun observeViewModel() {
viewModel.homeStateLiveData.observe(this) { state ->
when (state) {
is CreatorChannelHomeUiState.Content -> bindContent(state)
is CreatorChannelHomeUiState.Error -> Unit
CreatorChannelHomeUiState.Empty -> Unit
CreatorChannelHomeUiState.Loading -> Unit
}
}
viewModel.chatRoomIdLiveData.observe(this) { event ->
event.consume()?.let { chatRoomId ->
startActivity(ChatRoomActivity.newIntent(this, chatRoomId))
}
}
viewModel.toastLiveData.observe(this) { event ->
event.consume()?.let {
val message = it.message ?: it.resId?.let(::getString)
message?.let { text -> Toast.makeText(applicationContext, text, Toast.LENGTH_LONG).show() }
}
}
viewModel.isFollowInProgressLiveData.observe(this) { inProgress ->
isFollowInProgress = inProgress
currentHeader?.let(::bindTitleBar)
}
}
private fun bindContent(content: CreatorChannelHomeUiState.Content) {
currentHeader = content.header
bindHeader(content.header)
bindTitleBar(content.header)
bindTabs(content.tabs)
sectionAdapter.submitItems(content.sections)
}
private fun bindHeader(header: CreatorChannelHeaderUiModel) { private fun bindHeader(header: CreatorChannelHeaderUiModel) {
binding.tvNickname.text = header.nickname binding.tvNickname.text = header.nickname
binding.tvFollowerCount.text = getString( binding.tvFollowerCount.text = getString(
@@ -166,54 +122,11 @@ class CreatorChannelActivity : BaseActivity<ActivityCreatorChannelBinding>(
} }
} }
private fun bindTabs(tabs: List<CreatorChannelTab>) {
binding.tabContainer.removeAllViews()
tabs.forEach { tab ->
binding.tabContainer.addView(createTabView(tab, isSelected = tab == selectedTab))
}
}
private fun createTabView(tab: CreatorChannelTab, isSelected: Boolean): LinearLayout {
val tabText = TextView(this).apply {
text = getString(tab.labelResId)
gravity = Gravity.CENTER
setTextColor(getColor(if (isSelected) R.color.white else R.color.gray_500))
setTypeface(null, Typeface.NORMAL)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
0,
1f
)
}
tabText.textSize = 16f
val indicator = View(this).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
3.dpToPx().toInt()
)
}
indicator.setBackgroundColor(getColor(R.color.soda_400))
indicator.isVisible = isSelected
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.MATCH_PARENT
).apply {
width = 110.dpToPx().toInt()
}
addView(tabText)
addView(indicator)
setOnClickListener { onTabClicked(tab) }
}
}
private fun onFollowActionClicked() { private fun onFollowActionClicked() {
if (isFollowInProgress) return if (isFollowInProgress) return
val header = currentHeader ?: return val header = currentHeader ?: return
if (!header.isFollow) { if (!header.isFollow) {
viewModel.follow(follow = true, notify = true) homeActionDelegate?.follow(follow = true, notify = true)
return return
} }
@@ -222,20 +135,13 @@ class CreatorChannelActivity : BaseActivity<ActivityCreatorChannelBinding>(
private fun showFollowNotifyFragment() { private fun showFollowNotifyFragment() {
val notifyFragment = CreatorFollowNotifyFragment( val notifyFragment = CreatorFollowNotifyFragment(
onClickNotifyAll = { viewModel.follow(follow = true, notify = true) }, onClickNotifyAll = { homeActionDelegate?.follow(follow = true, notify = true) },
onClickNotifyNone = { viewModel.follow(follow = true, notify = false) }, onClickNotifyNone = { homeActionDelegate?.follow(follow = true, notify = false) },
onClickUnFollow = { viewModel.follow(follow = false, notify = false) } onClickUnFollow = { homeActionDelegate?.follow(follow = false, notify = false) }
) )
notifyFragment.show(supportFragmentManager, CreatorFollowNotifyFragment::class.java.simpleName) notifyFragment.show(supportFragmentManager, CreatorFollowNotifyFragment::class.java.simpleName)
} }
private fun onTabClicked(tab: CreatorChannelTab) {
if (tab != CreatorChannelTab.Home) return
selectedTab = CreatorChannelTab.Home
val content = viewModel.homeStateLiveData.value as? CreatorChannelHomeUiState.Content ?: return
bindTabs(content.tabs)
}
private fun setTitleBarTopInset() { private fun setTitleBarTopInset() {
ViewCompat.setOnApplyWindowInsetsListener(binding.titleBarContainer) { view, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.titleBarContainer) { view, insets ->
val topInset = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top val topInset = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top
@@ -262,7 +168,7 @@ class CreatorChannelActivity : BaseActivity<ActivityCreatorChannelBinding>(
if (headerHeight <= 0) return if (headerHeight <= 0) return
val tabTranslationY = (scrollY - (headerHeight - stickyTop)).coerceAtLeast(0) val tabTranslationY = (scrollY - (headerHeight - stickyTop)).coerceAtLeast(0)
binding.horizontalTabScrollView.translationY = tabTranslationY.toFloat() binding.tabLayout.translationY = tabTranslationY.toFloat()
val tabBarTop = headerHeight - scrollY + tabTranslationY val tabBarTop = headerHeight - scrollY + tabTranslationY
val profileVisibleHeight = (headerHeight - scrollY).coerceIn(0, headerHeight) val profileVisibleHeight = (headerHeight - scrollY).coerceIn(0, headerHeight)
@@ -293,6 +199,73 @@ class CreatorChannelActivity : BaseActivity<ActivityCreatorChannelBinding>(
Toast.makeText(applicationContext, getString(R.string.creator_channel_more_ready), Toast.LENGTH_SHORT).show() Toast.makeText(applicationContext, getString(R.string.creator_channel_more_ready), Toast.LENGTH_SHORT).show()
} }
private fun setupTabsAndPager() {
binding.viewPager.adapter = CreatorChannelPagerAdapter(this, creatorId)
binding.viewPager.isUserInputEnabled = true
binding.viewPager.offscreenPageLimit = CreatorChannelTab.entries.size - 1
tabLayoutMediator = TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position ->
tab.text = getString(CreatorChannelTab.entries[position].labelResId)
}.also {
it.attach()
}
val callback = object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
updateViewPagerHeight()
}
}
pageChangeCallback = callback
binding.viewPager.registerOnPageChangeCallback(callback)
}
override fun onCreatorChannelHeaderChanged(header: CreatorChannelHeaderUiModel) {
currentHeader = header
bindHeader(header)
bindTitleBar(header)
}
override fun onCreatorChannelFollowProgressChanged(inProgress: Boolean) {
isFollowInProgress = inProgress
currentHeader?.let(::bindTitleBar)
}
override fun onCreatorChannelChatRoomCreated(chatRoomId: Long) {
startActivity(ChatRoomActivity.newIntent(this, chatRoomId))
}
override fun onCreatorChannelScheduleClicked(schedule: CreatorChannelScheduleResponse) {
onScheduleClicked(schedule)
}
override fun onCreatorChannelAudioContentClicked(audioContent: CreatorChannelAudioContentResponse) {
onAudioContentClicked(audioContent)
}
override fun onCreatorChannelHomeActionDelegateReady(
delegate: CreatorChannelHomeFragment.HomeActionDelegate?
) {
homeActionDelegate = delegate
}
override fun onCreatorChannelHomeContentChanged() {
updateViewPagerHeight()
}
private fun updateViewPagerHeight() {
binding.viewPager.post {
val recyclerView = binding.viewPager.getChildAt(0) as? RecyclerView ?: return@post
val currentPage = recyclerView.layoutManager?.findViewByPosition(binding.viewPager.currentItem) ?: return@post
val widthSpec = MeasureSpec.makeMeasureSpec(binding.viewPager.width, MeasureSpec.EXACTLY)
val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
currentPage.measure(widthSpec, heightSpec)
val measuredHeight = currentPage.measuredHeight
if (measuredHeight <= 0 || binding.viewPager.layoutParams.height == measuredHeight) return@post
binding.viewPager.updateLayoutParams {
height = measuredHeight
}
}
}
private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse) { private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse) {
when (schedule.type) { when (schedule.type) {
CreatorActivityType.Audio, CreatorActivityType.Audio,
@@ -330,6 +303,15 @@ class CreatorChannelActivity : BaseActivity<ActivityCreatorChannelBinding>(
detailFragment.show(supportFragmentManager, detailFragment.tag) detailFragment.show(supportFragmentManager, detailFragment.tag)
} }
override fun onDestroy() {
tabLayoutMediator?.detach()
pageChangeCallback?.let { callback ->
binding.viewPager.unregisterOnPageChangeCallback(callback)
}
pageChangeCallback = null
super.onDestroy()
}
companion object { companion object {
const val EXTRA_CREATOR_ID: String = "extra_creator_id" const val EXTRA_CREATOR_ID: String = "extra_creator_id"

View File

@@ -45,6 +45,7 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
} }
override fun onDestroyView() { override fun onDestroyView() {
binding.rvHomeSections.adapter = null
host.onCreatorChannelHomeActionDelegateReady(null) host.onCreatorChannelHomeActionDelegateReady(null)
super.onDestroyView() super.onDestroyView()
} }
@@ -55,6 +56,7 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
is CreatorChannelHomeUiState.Content -> { is CreatorChannelHomeUiState.Content -> {
host.onCreatorChannelHeaderChanged(state.header) host.onCreatorChannelHeaderChanged(state.header)
sectionAdapter.submitItems(state.sections) sectionAdapter.submitItems(state.sections)
host.onCreatorChannelHomeContentChanged()
} }
is CreatorChannelHomeUiState.Error -> Unit is CreatorChannelHomeUiState.Error -> Unit
CreatorChannelHomeUiState.Empty -> Unit CreatorChannelHomeUiState.Empty -> Unit
@@ -90,6 +92,7 @@ class CreatorChannelHomeFragment : BaseFragment<FragmentCreatorChannelHomeBindin
fun onCreatorChannelScheduleClicked(schedule: CreatorChannelScheduleResponse) fun onCreatorChannelScheduleClicked(schedule: CreatorChannelScheduleResponse)
fun onCreatorChannelAudioContentClicked(audioContent: CreatorChannelAudioContentResponse) fun onCreatorChannelAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)
fun onCreatorChannelHomeActionDelegateReady(delegate: HomeActionDelegate?) fun onCreatorChannelHomeActionDelegateReady(delegate: HomeActionDelegate?)
fun onCreatorChannelHomeContentChanged()
} }
interface HomeActionDelegate { interface HomeActionDelegate {

View File

@@ -114,34 +114,22 @@
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
<HorizontalScrollView <com.google.android.material.tabs.TabLayout
android:id="@+id/horizontal_tab_scroll_view" android:id="@+id/tab_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="52dp" android:layout_height="52dp"
android:background="@color/black" android:background="@color/black"
android:elevation="1dp" android:elevation="1dp"
android:fillViewport="false" app:tabIndicatorColor="@color/soda_400"
android:overScrollMode="never" app:tabMode="scrollable"
android:scrollbars="none"> app:tabSelectedTextColor="@color/white"
app:tabTextColor="@color/gray_500" />
<LinearLayout <androidx.viewpager2.widget.ViewPager2
android:id="@+id/tab_container" android:id="@+id/view_pager"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_20" />
</HorizontalScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_home_sections"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="1dp"
android:clipToPadding="false" android:nestedScrollingEnabled="false" />
android:nestedScrollingEnabled="false"
android:paddingBottom="@dimen/spacing_32"
tools:itemCount="4"
tools:listitem="@layout/item_creator_channel_home_audio" />
</LinearLayout> </LinearLayout>
</androidx.core.widget.NestedScrollView> </androidx.core.widget.NestedScrollView>

View File

@@ -2,7 +2,7 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="160dp"
android:background="@color/black"> android:background="@color/black">
<TextView <TextView
@@ -11,6 +11,5 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:textColor="@color/gray_500" android:textColor="@color/gray_500"
android:visibility="gone"
tools:text="Live" /> tools:text="Live" />
</FrameLayout> </FrameLayout>

View File

@@ -35,17 +35,12 @@ class CreatorChannelActivitySourceTest {
assertTrue(source.contains("fun newIntent(context: Context, creatorId: Long): Intent")) assertTrue(source.contains("fun newIntent(context: Context, creatorId: Long): Intent"))
assertTrue(source.contains("Intent(context, CreatorChannelActivity::class.java)")) assertTrue(source.contains("Intent(context, CreatorChannelActivity::class.java)"))
assertTrue(source.contains("putExtra(EXTRA_CREATOR_ID, creatorId)")) assertTrue(source.contains("putExtra(EXTRA_CREATOR_ID, creatorId)"))
assertTrue(source.contains("private val viewModel: CreatorChannelHomeViewModel by viewModel()"))
assertTrue(source.contains("if (creatorId <= 0L)")) assertTrue(source.contains("if (creatorId <= 0L)"))
assertTrue(source.contains("finish()")) assertTrue(source.contains("finish()"))
assertTrue(source.contains("viewModel.loadHome(creatorId)"))
assertTrue(source.contains("viewModel.homeStateLiveData.observe(this)"))
assertTrue(source.contains("viewModel.chatRoomIdLiveData.observe(this)"))
assertFalse(source.contains("is CreatorChannelHomeUiState.Error -> showToast")) assertFalse(source.contains("is CreatorChannelHomeUiState.Error -> showToast"))
assertTrue(source.contains("event.consume()?.let"))
assertTrue(source.contains("ChatRoomActivity.newIntent(this, chatRoomId)")) assertTrue(source.contains("ChatRoomActivity.newIntent(this, chatRoomId)"))
assertTrue(source.contains("DmChatRoomActivity.newIntentByCreatorId(this, creatorId)")) assertTrue(source.contains("DmChatRoomActivity.newIntentByCreatorId(this, creatorId)"))
assertTrue(source.contains("viewModel.createChatRoom(characterId)")) assertTrue(source.contains("homeActionDelegate?.createChatRoom(characterId)"))
assertTrue(source.contains("updateActionButtonLayout")) assertTrue(source.contains("updateActionButtonLayout"))
assertTrue(source.contains("marginStart = if (isChatVisible && isDmVisible)")) assertTrue(source.contains("marginStart = if (isChatVisible && isDmVisible)"))
} }
@@ -61,32 +56,90 @@ class CreatorChannelActivitySourceTest {
assertTrue(source.contains("binding.ivBell.setOnClickListener")) assertTrue(source.contains("binding.ivBell.setOnClickListener"))
assertTrue(source.contains("private fun onFollowActionClicked")) assertTrue(source.contains("private fun onFollowActionClicked"))
assertTrue(source.contains("if (!header.isFollow)")) assertTrue(source.contains("if (!header.isFollow)"))
assertTrue(source.contains("viewModel.follow(follow = true, notify = true)")) assertTrue(source.contains("homeActionDelegate?.follow(follow = true, notify = true)"))
assertTrue(source.contains("showFollowNotifyFragment")) assertTrue(source.contains("showFollowNotifyFragment"))
assertTrue(source.contains("onClickNotifyAll = { viewModel.follow(follow = true, notify = true) }")) assertTrue(source.contains("onClickNotifyAll = { homeActionDelegate?.follow(follow = true, notify = true) }"))
assertTrue(source.contains("onClickNotifyNone = { viewModel.follow(follow = true, notify = false) }")) assertTrue(source.contains("onClickNotifyNone = { homeActionDelegate?.follow(follow = true, notify = false) }"))
assertTrue(source.contains("onClickUnFollow = { viewModel.follow(follow = false, notify = false) }")) assertTrue(source.contains("onClickUnFollow = { homeActionDelegate?.follow(follow = false, notify = false) }"))
assertTrue(source.contains("viewModel.isFollowInProgressLiveData.observe(this)")) assertTrue(source.contains("onCreatorChannelFollowProgressChanged"))
assertTrue(source.contains("binding.layoutFollowCapsule.isEnabled = titleBarState.isActionEnabled")) assertTrue(source.contains("binding.layoutFollowCapsule.isEnabled = titleBarState.isActionEnabled"))
assertTrue(source.contains("binding.ivBell.isEnabled = titleBarState.isActionEnabled")) assertTrue(source.contains("binding.ivBell.isEnabled = titleBarState.isActionEnabled"))
} }
@Test @Test
fun `layout source는 HorizontalScrollView 기반 7개 탭 컨테이너와 RecyclerView를 가진다`() { fun `Phase 10 컨테이너 layout은 TabLayout과 ViewPager2를 가진다`() {
val layout = projectFile("app/src/main/res/layout/activity_creator_channel.xml").readText() val layout = projectFile("app/src/main/res/layout/activity_creator_channel.xml").readText()
assertTrue(layout.contains("<com.google.android.material.tabs.TabLayout"))
assertTrue(layout.contains("@+id/tab_layout"))
assertTrue(layout.contains("<androidx.viewpager2.widget.ViewPager2"))
assertTrue(layout.contains("@+id/view_pager"))
assertFalse(layout.contains("<HorizontalScrollView"))
assertFalse(layout.contains("@+id/horizontal_tab_scroll_view"))
assertFalse(layout.contains("@+id/tab_container"))
assertFalse(layout.contains("@+id/rv_home_sections"))
}
@Test
fun `Phase 10 Activity는 pager adapter를 사용하고 홈 탭 구현을 Fragment로 위임한다`() {
val source = projectFile( val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText() ).readText()
assertTrue(layout.contains("<HorizontalScrollView")) assertTrue(source.contains("CreatorChannelPagerAdapter"))
assertTrue(layout.contains("@+id/horizontal_tab_scroll_view")) assertTrue(source.contains("binding.viewPager.adapter"))
assertTrue(layout.contains("android:elevation=\"1dp\"")) assertTrue(source.contains("CreatorChannelHomeFragment"))
assertTrue(layout.contains("@+id/tab_container")) assertFalse(source.contains("private val viewModel: CreatorChannelHomeViewModel by viewModel()"))
assertFalse(source.contains("CreatorChannelHomeSectionAdapter"))
assertFalse(source.contains("sectionAdapter"))
assertFalse(source.contains("viewModel.loadHome(creatorId)"))
assertFalse(source.contains("bindTabs(content.tabs)"))
assertFalse(source.contains("private fun createTabView"))
assertFalse(source.contains("private fun onTabClicked"))
}
@Test
fun `home Fragment source는 creatorId argument와 home ViewModel RecyclerView를 소유한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt"
).readText()
val layout = projectFile("app/src/main/res/layout/fragment_creator_channel_home.xml").readText()
assertTrue(source.contains("BaseFragment<FragmentCreatorChannelHomeBinding>"))
assertTrue(source.contains("private const val ARG_CREATOR_ID"))
assertTrue(source.contains("fun newInstance(creatorId: Long): CreatorChannelHomeFragment"))
assertTrue(source.contains("arguments = Bundle().apply"))
assertTrue(source.contains("putLong(ARG_CREATOR_ID, creatorId)"))
assertTrue(source.contains("private val viewModel: CreatorChannelHomeViewModel by viewModel()"))
assertTrue(source.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked, ::onAudioContentClicked)"))
assertTrue(source.contains("binding.rvHomeSections.layoutManager = LinearLayoutManager(requireContext())"))
assertTrue(source.contains("binding.rvHomeSections.adapter = sectionAdapter"))
assertTrue(source.contains("viewModel.homeStateLiveData.observe(viewLifecycleOwner)"))
assertTrue(source.contains("viewModel.chatRoomIdLiveData.observe(viewLifecycleOwner)"))
assertTrue(source.contains("viewModel.toastLiveData.observe(viewLifecycleOwner)"))
assertTrue(source.contains("viewModel.isFollowInProgressLiveData.observe(viewLifecycleOwner)"))
assertTrue(source.contains("host.onCreatorChannelHomeContentChanged()"))
assertTrue(source.contains("binding.rvHomeSections.adapter = null"))
assertTrue(source.contains("if (creatorId > 0L)"))
assertTrue(source.contains("viewModel.loadHome(creatorId)"))
assertTrue(layout.contains("@+id/rv_home_sections")) assertTrue(layout.contains("@+id/rv_home_sections"))
}
@Test
fun `layout source는 Activity 컨테이너와 홈 Fragment RecyclerView를 분리한다`() {
val layout = projectFile("app/src/main/res/layout/activity_creator_channel.xml").readText()
val fragmentLayout = projectFile("app/src/main/res/layout/fragment_creator_channel_home.xml").readText()
assertTrue(layout.contains("@+id/tab_layout"))
assertTrue(layout.contains("@+id/view_pager"))
assertTrue(layout.contains("android:layout_height=\"1dp\""))
assertFalse(layout.contains("@+id/horizontal_tab_scroll_view"))
assertFalse(layout.contains("@+id/tab_container"))
assertFalse(layout.contains("@+id/rv_home_sections"))
assertTrue(fragmentLayout.contains("@+id/rv_home_sections"))
assertTrue(fragmentLayout.contains("tools:listitem=\"@layout/item_creator_channel_home_audio\""))
assertTrue(layout.contains("android:drawableStart=\"@drawable/ic_new_talk\"")) assertTrue(layout.contains("android:drawableStart=\"@drawable/ic_new_talk\""))
assertTrue(layout.contains("android:drawableStart=\"@drawable/ic_new_dm\"")) assertTrue(layout.contains("android:drawableStart=\"@drawable/ic_new_dm\""))
assertFalse(layout.contains("TextTabBarView"))
assertTrue(source.contains("getString(tab.labelResId)"))
} }
@Test @Test
@@ -121,7 +174,7 @@ class CreatorChannelActivitySourceTest {
} }
@Test @Test
fun `scroll source는 tab sticky와 title bar black 전환을 연결한다`() { fun `scroll source는 tab sticky와 title bar black 전환을 유지한다`() {
val source = projectFile( val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText() ).readText()
@@ -129,7 +182,8 @@ class CreatorChannelActivitySourceTest {
assertTrue(source.contains("setupScrollListener")) assertTrue(source.contains("setupScrollListener"))
assertTrue(source.contains("binding.nestedScrollView.setOnScrollChangeListener")) assertTrue(source.contains("binding.nestedScrollView.setOnScrollChangeListener"))
assertTrue(source.contains("CreatorChannelScrollState.calculateStickyTop")) assertTrue(source.contains("CreatorChannelScrollState.calculateStickyTop"))
assertTrue(source.contains("binding.horizontalTabScrollView.translationY")) assertTrue(source.contains("binding.tabLayout.translationY = tabTranslationY.toFloat()"))
assertTrue(source.contains("val tabBarTop = headerHeight - scrollY + tabTranslationY"))
assertTrue(source.contains("CreatorChannelScrollState.shouldUseBlackTitleBar")) assertTrue(source.contains("CreatorChannelScrollState.shouldUseBlackTitleBar"))
assertTrue(source.contains("binding.titleBarContainer.setBackgroundColor")) assertTrue(source.contains("binding.titleBarContainer.setBackgroundColor"))
assertTrue(source.contains("Color.BLACK")) assertTrue(source.contains("Color.BLACK"))
@@ -147,29 +201,67 @@ class CreatorChannelActivitySourceTest {
} }
@Test @Test
fun `tab source는 Figma 기준 selected indicator와 16sp 고정 폭 탭을 사용한다`() { fun `pager adapter source는 7개 탭 순서와 홈 placeholder Fragment를 연결한다`() {
val source = projectFile( val adapter = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPagerAdapter.kt"
).readText()
val placeholder = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPlaceholderFragment.kt"
).readText()
val placeholderLayout = projectFile(
"app/src/main/res/layout/fragment_creator_channel_placeholder.xml"
).readText() ).readText()
assertTrue(source.contains("createTabView(tab, isSelected = tab == selectedTab)")) assertTrue(adapter.contains("class CreatorChannelPagerAdapter"))
assertTrue(source.contains("tabText.textSize = 16f")) assertTrue(adapter.contains("FragmentStateAdapter"))
assertTrue(source.contains("width = 110.dpToPx().toInt()")) assertTrue(adapter.contains("private val tabs: List<CreatorChannelTab> = CreatorChannelTab.entries"))
assertTrue(source.contains("indicator.setBackgroundColor(getColor(R.color.soda_400))")) assertTrue(adapter.contains("override fun getItemCount(): Int = tabs.size"))
assertTrue(source.contains("indicator.isVisible = isSelected")) assertTrue(adapter.contains("CreatorChannelTab.Home -> CreatorChannelHomeFragment.newInstance(creatorId)"))
assertTrue(adapter.contains("else -> CreatorChannelPlaceholderFragment.newInstance(tab)"))
assertTrue(placeholder.contains("private const val ARG_TAB_NAME"))
assertTrue(placeholder.contains("fun newInstance(tab: CreatorChannelTab): CreatorChannelPlaceholderFragment"))
assertTrue(placeholderLayout.contains("@+id/tv_placeholder"))
assertTrue(placeholderLayout.contains("android:layout_height=\"160dp\""))
assertFalse(placeholderLayout.contains("android:visibility=\"gone\""))
} }
@Test @Test
fun `tab source는 홈 기본 선택과 홈 외 탭 no op 정책을 명시한다`() { fun `tab source는 TabLayoutMediator와 ViewPager2를 연결한다`() {
val source = projectFile( val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText() ).readText()
assertTrue(source.contains("private var selectedTab: CreatorChannelTab = CreatorChannelTab.Home")) assertTrue(source.contains("TabLayoutMediator"))
assertTrue(source.contains("createTabView(tab, isSelected = tab == selectedTab)")) assertTrue(source.contains("private var tabLayoutMediator: TabLayoutMediator? = null"))
assertTrue(source.contains("setOnClickListener { onTabClicked(tab) }")) assertTrue(source.contains("private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null"))
assertTrue(source.contains("private fun onTabClicked(tab: CreatorChannelTab)")) assertTrue(source.contains("binding.viewPager.adapter = CreatorChannelPagerAdapter(this, creatorId)"))
assertTrue(source.contains("if (tab != CreatorChannelTab.Home) return")) assertTrue(source.contains("tabLayoutMediator = TabLayoutMediator(binding.tabLayout, binding.viewPager)"))
assertTrue(source.contains("tab.text = getString(CreatorChannelTab.entries[position].labelResId)"))
assertTrue(source.contains(".attach()"))
assertTrue(source.contains("binding.viewPager.isUserInputEnabled = true"))
assertTrue(source.contains("binding.viewPager.offscreenPageLimit = CreatorChannelTab.entries.size - 1"))
assertTrue(source.contains("binding.viewPager.registerOnPageChangeCallback(callback)"))
assertTrue(source.contains("override fun onDestroy()"))
assertTrue(source.contains("tabLayoutMediator?.detach()"))
assertTrue(source.contains("binding.viewPager.unregisterOnPageChangeCallback(callback)"))
assertTrue(source.contains("private fun updateViewPagerHeight()"))
assertTrue(source.contains("findViewByPosition(binding.viewPager.currentItem)"))
assertTrue(source.contains("currentPage.measure(widthSpec, heightSpec)"))
assertTrue(source.contains("binding.viewPager.updateLayoutParams"))
assertFalse(source.contains("binding.tabLayout.addTab"))
assertFalse(source.contains("private fun createTabView"))
}
@Test
fun `tab source는 기존 custom tab no op 정책을 제거한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText()
assertFalse(source.contains("private var selectedTab: CreatorChannelTab = CreatorChannelTab.Home"))
assertFalse(source.contains("setOnClickListener { onTabClicked(tab) }"))
assertFalse(source.contains("private fun onTabClicked(tab: CreatorChannelTab)"))
assertFalse(source.contains("if (tab != CreatorChannelTab.Home) return"))
assertFalse(source.contains("CreatorChannelTab.Live ->")) assertFalse(source.contains("CreatorChannelTab.Live ->"))
assertFalse(source.contains("CreatorChannelTab.Audio ->")) assertFalse(source.contains("CreatorChannelTab.Audio ->"))
assertFalse(source.contains("CreatorChannelTab.Series ->")) assertFalse(source.contains("CreatorChannelTab.Series ->"))
@@ -589,8 +681,13 @@ class CreatorChannelActivitySourceTest {
val source = projectFile( val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText() ).readText()
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt"
).readText()
assertTrue(source.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked")) assertTrue(fragment.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked"))
assertTrue(fragment.contains("private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse)"))
assertTrue(fragment.contains("host.onCreatorChannelScheduleClicked(schedule)"))
assertTrue(source.contains("private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse)")) assertTrue(source.contains("private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse)"))
assertTrue(source.contains("CreatorActivityType.Audio")) assertTrue(source.contains("CreatorActivityType.Audio"))
assertTrue(source.contains("CreatorActivityType.LiveReplay")) assertTrue(source.contains("CreatorActivityType.LiveReplay"))
@@ -663,8 +760,13 @@ class CreatorChannelActivitySourceTest {
val source = projectFile( val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt"
).readText() ).readText()
val fragment = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt"
).readText()
assertTrue(source.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked, ::onAudioContentClicked)")) assertTrue(fragment.contains("CreatorChannelHomeSectionAdapter(::onScheduleClicked, ::onAudioContentClicked)"))
assertTrue(fragment.contains("private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)"))
assertTrue(fragment.contains("host.onCreatorChannelAudioContentClicked(audioContent)"))
assertTrue(source.contains("private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)")) assertTrue(source.contains("private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)"))
assertTrue(source.contains("AudioContentDetailActivity::class.java")) assertTrue(source.contains("AudioContentDetailActivity::class.java"))
assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContent.audioContentId)")) assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContent.audioContentId)"))