feat(creator): 채널 홈 팔로우와 탭 동작을 연결한다

This commit is contained in:
2026-06-15 21:00:42 +09:00
parent bfb5440c9e
commit 19b8e4750f
4 changed files with 114 additions and 7 deletions

View File

@@ -10,11 +10,11 @@ import android.widget.LinearLayout
import android.widget.TextView 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.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.core.view.WindowInsetsCompat
import androidx.core.view.WindowCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
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
@@ -22,6 +22,7 @@ import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelHomeBinding import kr.co.vividnext.sodalive.databinding.ActivityCreatorChannelHomeBinding
import kr.co.vividnext.sodalive.explorer.profile.CreatorFollowNotifyFragment
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.extensions.moneyFormat import kr.co.vividnext.sodalive.extensions.moneyFormat
@@ -46,6 +47,8 @@ class CreatorChannelHomeActivity : BaseActivity<ActivityCreatorChannelHomeBindin
private val sectionAdapter = CreatorChannelHomeSectionAdapter(::onScheduleClicked, ::onAudioContentClicked) 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 isFollowInProgress: Boolean = false
private var statusBarHeight: Int = 0 private var statusBarHeight: Int = 0
private val baseTitleBarHeight: Int by lazy { 60.dpToPx().toInt() } private val baseTitleBarHeight: Int by lazy { 60.dpToPx().toInt() }
@@ -75,6 +78,8 @@ class CreatorChannelHomeActivity : BaseActivity<ActivityCreatorChannelHomeBindin
private fun setupClickListeners() { private fun setupClickListeners() {
binding.ivBack.setOnClickListener { finish() } binding.ivBack.setOnClickListener { finish() }
binding.ivMore.setOnClickListener { onMoreClicked() } binding.ivMore.setOnClickListener { onMoreClicked() }
binding.layoutFollowCapsule.setOnClickListener { onFollowActionClicked() }
binding.ivBell.setOnClickListener { onFollowActionClicked() }
binding.tvChatButton.setOnClickListener { binding.tvChatButton.setOnClickListener {
currentHeader?.characterId?.let { characterId -> viewModel.createChatRoom(characterId) } currentHeader?.characterId?.let { characterId -> viewModel.createChatRoom(characterId) }
} }
@@ -103,6 +108,10 @@ class CreatorChannelHomeActivity : BaseActivity<ActivityCreatorChannelHomeBindin
message?.let { text -> Toast.makeText(applicationContext, text, Toast.LENGTH_LONG).show() } 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) { private fun bindContent(content: CreatorChannelHomeUiState.Content) {
@@ -133,7 +142,7 @@ class CreatorChannelHomeActivity : BaseActivity<ActivityCreatorChannelHomeBindin
val titleBarState = CreatorChannelTitleBarState.from( val titleBarState = CreatorChannelTitleBarState.from(
isFollow = header.isFollow, isFollow = header.isFollow,
isNotify = header.isNotify, isNotify = header.isNotify,
isInProgress = false isInProgress = isFollowInProgress
) )
binding.ivFollow.setImageResource(titleBarState.followIconResId) binding.ivFollow.setImageResource(titleBarState.followIconResId)
binding.layoutFollowCapsule.isEnabled = titleBarState.isActionEnabled binding.layoutFollowCapsule.isEnabled = titleBarState.isActionEnabled
@@ -150,6 +159,7 @@ class CreatorChannelHomeActivity : BaseActivity<ActivityCreatorChannelHomeBindin
binding.tvFollowLabel.isVisible = !header.isFollow binding.tvFollowLabel.isVisible = !header.isFollow
titleBarState.bellIconResId?.let { titleBarState.bellIconResId?.let {
binding.ivBell.setImageResource(it) binding.ivBell.setImageResource(it)
binding.ivBell.isEnabled = titleBarState.isActionEnabled
binding.ivBell.visibility = View.VISIBLE binding.ivBell.visibility = View.VISIBLE
} ?: run { } ?: run {
binding.ivBell.visibility = View.GONE binding.ivBell.visibility = View.GONE
@@ -158,8 +168,8 @@ class CreatorChannelHomeActivity : BaseActivity<ActivityCreatorChannelHomeBindin
private fun bindTabs(tabs: List<CreatorChannelTab>) { private fun bindTabs(tabs: List<CreatorChannelTab>) {
binding.tabContainer.removeAllViews() binding.tabContainer.removeAllViews()
tabs.forEachIndexed { index, tab -> tabs.forEach { tab ->
binding.tabContainer.addView(createTabView(tab, isSelected = index == 0)) binding.tabContainer.addView(createTabView(tab, isSelected = tab == selectedTab))
} }
} }
@@ -195,9 +205,37 @@ class CreatorChannelHomeActivity : BaseActivity<ActivityCreatorChannelHomeBindin
} }
addView(tabText) addView(tabText)
addView(indicator) addView(indicator)
setOnClickListener { onTabClicked(tab) }
} }
} }
private fun onFollowActionClicked() {
if (isFollowInProgress) return
val header = currentHeader ?: return
if (!header.isFollow) {
viewModel.follow(follow = true, notify = true)
return
}
showFollowNotifyFragment()
}
private fun showFollowNotifyFragment() {
val notifyFragment = CreatorFollowNotifyFragment(
onClickNotifyAll = { viewModel.follow(follow = true, notify = true) },
onClickNotifyNone = { viewModel.follow(follow = true, notify = false) },
onClickUnFollow = { viewModel.follow(follow = false, notify = false) }
)
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

View File

@@ -29,6 +29,10 @@ class CreatorChannelHomeViewModel(
val chatRoomIdLiveData: LiveData<CreatorChannelEvent<Long>> val chatRoomIdLiveData: LiveData<CreatorChannelEvent<Long>>
get() = _chatRoomIdLiveData get() = _chatRoomIdLiveData
private val _isFollowInProgressLiveData = MutableLiveData(false)
val isFollowInProgressLiveData: LiveData<Boolean>
get() = _isFollowInProgressLiveData
private var isFollowInProgress = false private var isFollowInProgress = false
private var isCreateChatRoomInProgress = false private var isCreateChatRoomInProgress = false
@@ -62,6 +66,7 @@ class CreatorChannelHomeViewModel(
if (isFollowInProgress) return if (isFollowInProgress) return
isFollowInProgress = true isFollowInProgress = true
_isFollowInProgressLiveData.value = true
compositeDisposable.add( compositeDisposable.add(
repository.followCreator( repository.followCreator(
creatorId = content.header.creatorId, creatorId = content.header.creatorId,
@@ -74,6 +79,7 @@ class CreatorChannelHomeViewModel(
.subscribe( .subscribe(
{ {
isFollowInProgress = false isFollowInProgress = false
_isFollowInProgressLiveData.value = false
if (it.success) { if (it.success) {
_homeStateLiveData.value = content.copy( _homeStateLiveData.value = content.copy(
header = content.header.copy(isFollow = follow, isNotify = notify) header = content.header.copy(isFollow = follow, isNotify = notify)
@@ -84,6 +90,7 @@ class CreatorChannelHomeViewModel(
}, },
{ {
isFollowInProgress = false isFollowInProgress = false
_isFollowInProgressLiveData.value = false
it.message?.let { message -> Logger.e(message) } it.message?.let { message -> Logger.e(message) }
showUnknownErrorToast() showUnknownErrorToast()
} }

View File

@@ -48,7 +48,27 @@ class CreatorChannelHomeActivitySourceTest {
assertTrue(source.contains("viewModel.createChatRoom(characterId)")) assertTrue(source.contains("viewModel.createChatRoom(characterId)"))
assertTrue(source.contains("updateActionButtonLayout")) assertTrue(source.contains("updateActionButtonLayout"))
assertTrue(source.contains("marginStart = if (isChatVisible && isDmVisible)")) assertTrue(source.contains("marginStart = if (isChatVisible && isDmVisible)"))
assertFalse(source.contains("CreatorFollowNotifyFragment")) }
@Test
fun `follow notify source는 미팔로우 직접 팔로우와 팔로우 중 알림 sheet를 연결한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText()
assertTrue(source.contains("CreatorFollowNotifyFragment"))
assertTrue(source.contains("binding.layoutFollowCapsule.setOnClickListener"))
assertTrue(source.contains("binding.ivBell.setOnClickListener"))
assertTrue(source.contains("private fun onFollowActionClicked"))
assertTrue(source.contains("if (!header.isFollow)"))
assertTrue(source.contains("viewModel.follow(follow = true, notify = true)"))
assertTrue(source.contains("showFollowNotifyFragment"))
assertTrue(source.contains("onClickNotifyAll = { viewModel.follow(follow = true, notify = true) }"))
assertTrue(source.contains("onClickNotifyNone = { viewModel.follow(follow = true, notify = false) }"))
assertTrue(source.contains("onClickUnFollow = { viewModel.follow(follow = false, notify = false) }"))
assertTrue(source.contains("viewModel.isFollowInProgressLiveData.observe(this)"))
assertTrue(source.contains("binding.layoutFollowCapsule.isEnabled = titleBarState.isActionEnabled"))
assertTrue(source.contains("binding.ivBell.isEnabled = titleBarState.isActionEnabled"))
} }
@Test @Test
@@ -132,13 +152,32 @@ class CreatorChannelHomeActivitySourceTest {
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt" "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText() ).readText()
assertTrue(source.contains("createTabView(tab, isSelected = index == 0)")) assertTrue(source.contains("createTabView(tab, isSelected = tab == selectedTab)"))
assertTrue(source.contains("tabText.textSize = 16f")) assertTrue(source.contains("tabText.textSize = 16f"))
assertTrue(source.contains("width = 110.dpToPx().toInt()")) assertTrue(source.contains("width = 110.dpToPx().toInt()"))
assertTrue(source.contains("indicator.setBackgroundColor(getColor(R.color.soda_400))")) assertTrue(source.contains("indicator.setBackgroundColor(getColor(R.color.soda_400))"))
assertTrue(source.contains("indicator.isVisible = isSelected")) assertTrue(source.contains("indicator.isVisible = isSelected"))
} }
@Test
fun `tab source는 홈 기본 선택과 홈 외 탭 no op 정책을 명시한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeActivity.kt"
).readText()
assertTrue(source.contains("private var selectedTab: CreatorChannelTab = CreatorChannelTab.Home"))
assertTrue(source.contains("createTabView(tab, isSelected = tab == selectedTab)"))
assertTrue(source.contains("setOnClickListener { onTabClicked(tab) }"))
assertTrue(source.contains("private fun onTabClicked(tab: CreatorChannelTab)"))
assertTrue(source.contains("if (tab != CreatorChannelTab.Home) return"))
assertFalse(source.contains("CreatorChannelTab.Live ->"))
assertFalse(source.contains("CreatorChannelTab.Audio ->"))
assertFalse(source.contains("CreatorChannelTab.Series ->"))
assertFalse(source.contains("CreatorChannelTab.Community ->"))
assertFalse(source.contains("CreatorChannelTab.FanTalk ->"))
assertFalse(source.contains("CreatorChannelTab.Donation ->"))
}
@Test @Test
fun `section adapter source는 활동 지표를 행 단위 resource label로 표시한다`() { fun `section adapter source는 활동 지표를 행 단위 resource label로 표시한다`() {
val adapter = projectFile( val adapter = projectFile(
@@ -940,6 +979,27 @@ class CreatorChannelHomeActivitySourceTest {
assertTrue(manifest.contains(".v2.creator.channel.CreatorChannelHomeActivity")) assertTrue(manifest.contains(".v2.creator.channel.CreatorChannelHomeActivity"))
} }
@Test
fun `기존 크리에이터 채널 진입점은 UserProfileActivity 대신 CreatorChannelHomeActivity로 이동한다`() {
val sourceRoot = projectFile("app/src/main/java")
val directUserProfileRoutes = sourceRoot
.walkTopDown()
.filter { it.isFile && it.extension in setOf("kt", "java") }
.filterNot { it.name == "UserProfileActivity.kt" }
.filter { file ->
val source = file.readText()
source.contains("UserProfileActivity::class.java") ||
source.contains("import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity")
}
.map { it.relativeTo(sourceRoot).path }
.toList()
assertTrue(
"UserProfileActivity direct routes remain: $directUserProfileRoutes",
directUserProfileRoutes.isEmpty()
)
}
@Test @Test
fun `채팅과 DM Activity intent helper 계약을 참조한다`() { fun `채팅과 DM Activity intent helper 계약을 참조한다`() {
val chatRoom = projectFile("app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt").readText() val chatRoom = projectFile("app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt").readText()

View File

@@ -149,8 +149,10 @@ class CreatorChannelHomeViewModelTest {
viewModel.follow(follow = false, notify = false) viewModel.follow(follow = false, notify = false)
viewModel.follow(follow = false, notify = false) viewModel.follow(follow = false, notify = false)
assertEquals(true, viewModel.isFollowInProgressLiveData.requireValue())
verify(repository, times(1)).followCreator(100L, false, false, "Bearer test-token") verify(repository, times(1)).followCreator(100L, false, false, "Bearer test-token")
pending.onSuccess(ApiResponse(true, Any(), null)) pending.onSuccess(ApiResponse(true, Any(), null))
assertEquals(false, viewModel.isFollowInProgressLiveData.requireValue())
} }
@Test @Test