feat(main): 홈 크리에이터 FAB를 추가한다

This commit is contained in:
2026-07-17 00:08:33 +09:00
parent 831533b4f6
commit 16aa946621
9 changed files with 686 additions and 4 deletions

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.v2.main
import kr.co.vividnext.sodalive.settings.notification.MemberRole
object MainHomeFabVisibilityPolicy {
fun shouldShow(
currentTab: MainV2Tab?,
isLoginAllowed: Boolean,
role: String
): Boolean {
return currentTab == MainV2Tab.HOME &&
isLoginAllowed &&
role == MemberRole.CREATOR.name
}
}

View File

@@ -1,5 +1,8 @@
package kr.co.vividnext.sodalive.v2.main package kr.co.vividnext.sodalive.v2.main
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
@@ -12,10 +15,13 @@ import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.View import android.view.View
import android.view.animation.Interpolator
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
@@ -37,14 +43,17 @@ import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerFragment import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerFragment
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerService
import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity
import kr.co.vividnext.sodalive.audition.AuditionActivity import kr.co.vividnext.sodalive.audition.AuditionActivity
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityMainV2Binding import kr.co.vividnext.sodalive.databinding.ActivityMainV2Binding
import kr.co.vividnext.sodalive.explorer.profile.creator_community.write.CreatorCommunityWriteActivity
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import kr.co.vividnext.sodalive.live.LiveViewModel import kr.co.vividnext.sodalive.live.LiveViewModel
import kr.co.vividnext.sodalive.live.room.create.LiveRoomCreateActivity
import kr.co.vividnext.sodalive.main.EventPopupDialogFragment import kr.co.vividnext.sodalive.main.EventPopupDialogFragment
import kr.co.vividnext.sodalive.message.MessageActivity import kr.co.vividnext.sodalive.message.MessageActivity
import kr.co.vividnext.sodalive.mypage.MyPageFragment import kr.co.vividnext.sodalive.mypage.MyPageFragment
@@ -63,6 +72,8 @@ import kr.co.vividnext.sodalive.v2.content.action.handleContentAction
import kr.co.vividnext.sodalive.v2.creator.action.CreatorActionCommand import kr.co.vividnext.sodalive.v2.creator.action.CreatorActionCommand
import kr.co.vividnext.sodalive.v2.creator.action.handleCreatorAction import kr.co.vividnext.sodalive.v2.creator.action.handleCreatorAction
import kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinator import kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinator
import kr.co.vividnext.sodalive.v2.live.action.LiveCreationResult
import kr.co.vividnext.sodalive.v2.live.action.resolveLiveCreationResult
import kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragment import kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragment
import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomFilter
import kr.co.vividnext.sodalive.v2.main.content.ContentMainFragment import kr.co.vividnext.sodalive.v2.main.content.ContentMainFragment
@@ -101,6 +112,26 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
private var playerStateJob: Job? = null private var playerStateJob: Job? = null
private var isDeferredRouteLoading = false private var isDeferredRouteLoading = false
private var isLiveEntryLoading = false private var isLiveEntryLoading = false
private var isMainHomeFabExpanded: Boolean = false
private var isMainHomeFabAnimating: Boolean = false
private val communityWriteLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
findHomeMainFragment()?.refreshHome()
}
}
private val liveRoomCreateLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
handleLiveCreationResult(
resolveLiveCreationResult(
isSuccessful = result.resultCode == RESULT_OK,
roomId = result.data?.getLongExtra(Constants.EXTRA_ROOM_ID, 0L),
channelName = result.data?.getStringExtra(Constants.EXTRA_ROOM_CHANNEL_NAME)
)
)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -200,6 +231,7 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
} }
setupLiveEntryObservers() setupLiveEntryObservers()
setupMainHomeFabClickListeners()
setupBottomNavigation() setupBottomNavigation()
if (intent.hasExtra(EXTRA_CHAT_FILTER)) { if (intent.hasExtra(EXTRA_CHAT_FILTER)) {
selectChatTabWithLoginGuard() selectChatTabWithLoginGuard()
@@ -225,6 +257,22 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
} }
} }
private fun handleLiveCreationResult(result: LiveCreationResult) {
when (result) {
LiveCreationResult.Ignored -> Unit
LiveCreationResult.RefreshOnly -> findHomeMainFragment()?.refreshHome()
LiveCreationResult.Created -> {
findHomeMainFragment()?.refreshHome()
showToast(getString(R.string.creator_channel_live_created_message))
}
is LiveCreationResult.Enter -> {
findHomeMainFragment()?.refreshHome()
liveActionCoordinator.enterLiveRoom(result.roomId)
}
}
}
fun openChatTab() { fun openChatTab() {
selectChatTabWithLoginGuard() selectChatTabWithLoginGuard()
} }
@@ -296,7 +344,11 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
binding.bottomNavigation.selectedItemId = itemId binding.bottomNavigation.selectedItemId = itemId
} }
if (tab != MainV2Tab.HOME) {
collapseMainHomeFab(animate = false)
}
changeFragment(tab) changeFragment(tab)
updateMainHomeFabVisibility()
} }
} }
@@ -337,6 +389,133 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
} }
} }
private fun setupMainHomeFabClickListeners() {
binding.mainHomeFabButton.setOnClickListener { expandMainHomeFab() }
binding.mainHomeFabDim.setOnClickListener { collapseMainHomeFab() }
binding.mainHomeFabCloseButton.setOnClickListener { collapseMainHomeFab() }
binding.mainHomeFabCommunityButton.setOnClickListener { onMainHomeFabCommunityClicked() }
binding.mainHomeFabAudioButton.setOnClickListener { onMainHomeFabAudioClicked() }
binding.mainHomeFabLiveButton.setOnClickListener { onMainHomeFabLiveClicked() }
}
private fun expandMainHomeFab() {
if (isMainHomeFabAnimating) return
if (isMainHomeFabExpanded) return
if (!isMainHomeFabAvailable()) return
isMainHomeFabExpanded = true
animateMainHomeFab(expand = true)
}
private fun collapseMainHomeFab(animate: Boolean = true) {
if (isMainHomeFabAnimating) return
if (!isMainHomeFabExpanded) {
updateMainHomeFabVisibility()
return
}
isMainHomeFabExpanded = false
if (animate) {
animateMainHomeFab(expand = false)
} else {
updateMainHomeFabVisibility()
}
}
private fun animateMainHomeFab(expand: Boolean) {
isMainHomeFabAnimating = true
binding.mainHomeFabDim.isVisible = true
binding.mainHomeFabExpandedContainer.isVisible = true
binding.mainHomeFabButton.isVisible = true
val start = if (expand) 0f else 1f
val end = if (expand) 1f else 0f
ValueAnimator.ofFloat(start, end).apply {
duration = MAIN_HOME_FAB_ANIMATION_DURATION_MS
interpolator = SpringInterpolator(
mass = MAIN_HOME_FAB_SPRING_MASS,
stiffness = MAIN_HOME_FAB_SPRING_STIFFNESS,
damping = MAIN_HOME_FAB_SPRING_DAMPING
)
addUpdateListener { animator ->
val value = animator.animatedValue as Float
binding.mainHomeFabDim.alpha = value
binding.mainHomeFabExpandedContainer.alpha = value
binding.mainHomeFabExpandedContainer.scaleX = value
binding.mainHomeFabExpandedContainer.scaleY = value
binding.mainHomeFabButton.alpha = 1f - value
}
addListener(
onEnd = {
isMainHomeFabAnimating = false
updateMainHomeFabVisibility()
}
)
start()
}
}
private fun updateMainHomeFabVisibility() {
val shouldShowMainHomeFab = MainHomeFabVisibilityPolicy.shouldShow(
currentTab = viewModel.currentTab.value,
isLoginAllowed = isV2AccessAllowed(AccessRequirement.Login),
role = SharedPreferenceManager.role
)
binding.mainHomeFabDim.isVisible = shouldShowMainHomeFab && isMainHomeFabExpanded
binding.mainHomeFabExpandedContainer.isVisible = shouldShowMainHomeFab && isMainHomeFabExpanded
binding.mainHomeFabButton.isVisible = shouldShowMainHomeFab && !isMainHomeFabExpanded
if (!shouldShowMainHomeFab) {
isMainHomeFabExpanded = false
}
if (!binding.mainHomeFabExpandedContainer.isVisible) {
binding.mainHomeFabDim.alpha = 1f
binding.mainHomeFabExpandedContainer.alpha = 1f
binding.mainHomeFabExpandedContainer.scaleX = 1f
binding.mainHomeFabExpandedContainer.scaleY = 1f
binding.mainHomeFabButton.alpha = 1f
}
}
private fun isMainHomeFabAvailable(): Boolean {
return MainHomeFabVisibilityPolicy.shouldShow(
currentTab = viewModel.currentTab.value,
isLoginAllowed = isV2AccessAllowed(AccessRequirement.Login),
role = SharedPreferenceManager.role
)
}
private fun runMainHomeFabAction(action: () -> Unit) {
collapseMainHomeFab(animate = false)
ensureV2Access(AccessRequirement.Login) {
if (isMainHomeFabAvailable()) {
action()
} else {
updateMainHomeFabVisibility()
}
}
}
private fun onMainHomeFabCommunityClicked() {
runMainHomeFabAction {
communityWriteLauncher.launch(Intent(this, CreatorCommunityWriteActivity::class.java))
}
}
private fun onMainHomeFabAudioClicked() {
runMainHomeFabAction {
startActivity(Intent(this, AudioContentUploadActivity::class.java))
}
}
private fun onMainHomeFabLiveClicked() {
runMainHomeFabAction {
liveRoomCreateLauncher.launch(Intent(this, LiveRoomCreateActivity::class.java))
}
}
private fun findHomeMainFragment(): HomeMainFragment? {
return supportFragmentManager.findFragmentByTag(MainV2Tab.HOME.toString()) as? HomeMainFragment
}
private fun observePlayerState() { private fun observePlayerState() {
playerStateJob = lifecycleScope.launch { playerStateJob = lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -546,9 +725,17 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
private fun getMemberInfo() { private fun getMemberInfo() {
if (isV2AccessAllowed(AccessRequirement.Login)) { if (isV2AccessAllowed(AccessRequirement.Login)) {
viewModel.getMemberInfo(context = applicationContext) { viewModel.getMemberInfo(
context = applicationContext,
showNotificationSettingsDialog = {
notificationSettingsDialog.show(screenWidth) notificationSettingsDialog.show(screenWidth)
},
onMemberInfoUpdated = {
updateMainHomeFabVisibility()
} }
)
} else {
updateMainHomeFabVisibility()
} }
} }
@@ -894,7 +1081,30 @@ class MainV2Activity : BaseActivity<ActivityMainV2Binding>(ActivityMainV2Binding
} }
} }
private fun ValueAnimator.addListener(onEnd: () -> Unit) {
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) = onEnd()
})
}
private class SpringInterpolator(
private val mass: Float,
private val stiffness: Float,
private val damping: Float
) : Interpolator {
override fun getInterpolation(input: Float): Float {
val angularFrequency = kotlin.math.sqrt(stiffness / mass)
val decay = kotlin.math.exp(-damping / (2f * mass) * input)
val oscillation = kotlin.math.cos(angularFrequency * input)
return (1f - decay * oscillation).coerceIn(0f, 1f)
}
}
companion object { companion object {
private const val MAIN_HOME_FAB_ANIMATION_DURATION_MS = 260L
private const val MAIN_HOME_FAB_SPRING_MASS = 1f
private const val MAIN_HOME_FAB_SPRING_STIFFNESS = 256f
private const val MAIN_HOME_FAB_SPRING_DAMPING = 24f
private const val EXTRA_CHAT_FILTER: String = "extra_chat_filter" private const val EXTRA_CHAT_FILTER: String = "extra_chat_filter"
const val EXTRA_AUDIO_NOTIFICATION_ROUTE: String = "extra_audio_notification_route" const val EXTRA_AUDIO_NOTIFICATION_ROUTE: String = "extra_audio_notification_route"
const val ROUTE_AUDIO_PLAYER: String = "audio_player" const val ROUTE_AUDIO_PLAYER: String = "audio_player"

View File

@@ -78,7 +78,11 @@ class MainV2ViewModel(
) )
} }
fun getMemberInfo(context: Context, showNotificationSettingsDialog: () -> Unit) { fun getMemberInfo(
context: Context,
showNotificationSettingsDialog: () -> Unit,
onMemberInfoUpdated: () -> Unit = {}
) {
compositeDisposable.add( compositeDisposable.add(
userRepository.getMemberInfo(token = "Bearer ${SharedPreferenceManager.token}") userRepository.getMemberInfo(token = "Bearer ${SharedPreferenceManager.token}")
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
@@ -136,6 +140,7 @@ class MainV2ViewModel(
params = params params = params
) )
FirebaseTracking.login("email") FirebaseTracking.login("email")
onMemberInfoUpdated()
} }
}, },
{} {}

View File

@@ -159,6 +159,10 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
homeRecommendationViewModel.loadRecommendations() homeRecommendationViewModel.loadRecommendations()
} }
fun refreshHome() {
homeRecommendationViewModel.loadRecommendations()
}
private fun setupTitleBarActions() { private fun setupTitleBarActions() {
binding.viewHomeTitleBar.ivTitleBarCash.setOnClickListener { binding.viewHomeTitleBar.ivTitleBarCash.setOnClickListener {
openWithLoginGuard(CanChargeActivity::class.java) openWithLoginGuard(CanChargeActivity::class.java)

View File

@@ -105,4 +105,131 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/menu_main_v2_bottom_navigation" /> app:menu="@menu/menu_main_v2_bottom_navigation" />
<View
android:id="@+id/main_home_fab_dim"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="#66000000"
android:clickable="true"
android:focusable="true"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/main_home_fab_expanded_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_14"
android:layout_marginBottom="@dimen/spacing_14"
android:gravity="end"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/cl_mini_player"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible">
<FrameLayout
android:id="@+id/main_home_fab_community_button"
android:layout_width="66dp"
android:layout_height="66dp"
android:background="@drawable/bg_creator_channel_owner_fab"
android:clickable="true"
android:contentDescription="@string/creator_channel_owner_fab_community"
android:elevation="8dp"
android:focusable="true"
android:padding="@dimen/spacing_14">
<ImageView
android:layout_width="38dp"
android:layout_height="38dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_upload_community_post" />
</FrameLayout>
<FrameLayout
android:id="@+id/main_home_fab_audio_button"
android:layout_width="66dp"
android:layout_height="66dp"
android:layout_marginTop="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_owner_fab"
android:clickable="true"
android:contentDescription="@string/creator_channel_owner_fab_audio"
android:elevation="8dp"
android:focusable="true"
android:padding="@dimen/spacing_14">
<ImageView
android:layout_width="38dp"
android:layout_height="38dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_upload_audio" />
</FrameLayout>
<FrameLayout
android:id="@+id/main_home_fab_live_button"
android:layout_width="66dp"
android:layout_height="66dp"
android:layout_marginTop="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_owner_fab_live"
android:clickable="true"
android:contentDescription="@string/creator_channel_owner_fab_live"
android:elevation="8dp"
android:focusable="true"
android:padding="@dimen/spacing_14">
<ImageView
android:layout_width="38dp"
android:layout_height="38dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_create_live" />
</FrameLayout>
<FrameLayout
android:id="@+id/main_home_fab_close_button"
android:layout_width="66dp"
android:layout_height="66dp"
android:layout_marginTop="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_owner_fab_close"
android:clickable="true"
android:contentDescription="@string/creator_channel_owner_fab_close"
android:elevation="8dp"
android:focusable="true"
android:padding="@dimen/spacing_14">
<ImageView
android:layout_width="38dp"
android:layout_height="38dp"
android:contentDescription="@null"
android:src="@drawable/ic_new_x_black" />
</FrameLayout>
</LinearLayout>
<FrameLayout
android:id="@+id/main_home_fab_button"
android:layout_width="66dp"
android:layout_height="66dp"
android:layout_marginEnd="@dimen/spacing_14"
android:layout_marginBottom="@dimen/spacing_14"
android:background="@drawable/bg_creator_channel_owner_fab"
android:clickable="true"
android:contentDescription="@string/creator_channel_owner_fab_open"
android:elevation="8dp"
android:focusable="true"
android:padding="@dimen/spacing_14"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/cl_mini_player"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible">
<ImageView
android:layout_width="38dp"
android:layout_height="38dp"
android:contentDescription="@null"
android:src="@drawable/ic_plus_no_bg" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,53 @@
package kr.co.vividnext.sodalive.v2.main
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MainHomeFabVisibilityPolicyTest {
@Test
fun `홈 탭 로그인 크리에이터이면 FAB를 표시한다`() {
val shouldShow = MainHomeFabVisibilityPolicy.shouldShow(
currentTab = MainV2Tab.HOME,
isLoginAllowed = true,
role = MemberRole.CREATOR.name
)
assertTrue(shouldShow)
}
@Test
fun `홈 탭이어도 로그인하지 않았으면 FAB를 숨긴다`() {
val shouldShow = MainHomeFabVisibilityPolicy.shouldShow(
currentTab = MainV2Tab.HOME,
isLoginAllowed = false,
role = MemberRole.CREATOR.name
)
assertFalse(shouldShow)
}
@Test
fun `홈 탭이어도 일반 유저이면 FAB를 숨긴다`() {
val shouldShow = MainHomeFabVisibilityPolicy.shouldShow(
currentTab = MainV2Tab.HOME,
isLoginAllowed = true,
role = MemberRole.USER.name
)
assertFalse(shouldShow)
}
@Test
fun `크리에이터여도 홈 탭이 아니면 FAB를 숨긴다`() {
val shouldShow = MainHomeFabVisibilityPolicy.shouldShow(
currentTab = MainV2Tab.CONTENT,
isLoginAllowed = true,
role = MemberRole.CREATOR.name
)
assertFalse(shouldShow)
}
}

View File

@@ -0,0 +1,169 @@
# 메인 V2 홈 FAB 구현 계획/TASK
## Architecture
`MainV2Activity``CreatorChannelActivity` owner FAB와 동일한 overlay UI와 상태 제어 로직을 추가한다. 표시 조건은 `currentTab == MainV2Tab.HOME`, `isV2AccessAllowed(AccessRequirement.Login)`, `SharedPreferenceManager.role == MemberRole.CREATOR.name`의 AND 조건으로 제한한다. 액션 진입은 기존 Activity를 재사용하고, 라이브 생성 결과 처리는 기존 `LiveCreationResult`/`LiveActionCoordinator` 패턴을 따른다.
## Success Criteria
- 로그인한 크리에이터가 `MainV2Activity` 홈 탭에 있을 때 우측 하단 FAB가 표시된다.
- 일반 유저와 비로그인 유저에게 FAB가 표시되지 않는다.
- 콘텐츠/채팅/마이 탭으로 이동하면 FAB가 숨겨지고 확장 상태가 닫힌다.
- FAB 확장 UI와 각 버튼 기능은 `CreatorChannelActivity` 홈 탭 FAB와 동일하다.
- 기존 CreatorChannel FAB, 미니 플레이어, 하단 내비게이션 동작을 회귀시키지 않는다.
---
### Phase 1: 기존 패턴 및 표시 조건 확정
- [x] **Task 1.1: CreatorChannel FAB 구조 확인**
- 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt`, `app/src/main/res/layout/activity_creator_channel.xml`
- 확인 내용:
- `ownerFabButton`, `ownerFabExpandedContainer`, `ownerFabDim`, `ownerFabCloseButton` 구조를 재사용한다.
- 액션은 `CreatorCommunityWriteActivity`, `AudioContentUploadActivity`, `LiveRoomCreateActivity`로 이동한다.
- Live 생성 결과는 `resolveLiveCreationResult()``LiveActionCoordinator.enterLiveRoom()`을 사용한다.
- 검증 기록:
- 2026-07-16: `CreatorChannelActivity``activity_creator_channel.xml`을 읽어 홈 탭 owner FAB의 UI, 애니메이션, 액션 연결을 확인했다.
- [x] **Task 1.2: MainV2 표시 조건 확인**
- 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`, `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2ViewModel.kt`, `app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt`, `app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt`
- 확인 내용:
- `MainV2Activity``viewModel.currentTab` 관찰로 탭 변경을 처리한다.
- `MainV2ViewModel.getMemberInfo()``SharedPreferenceManager.role = data.role.name`을 갱신한다.
- 기존 코드에서 크리에이터 판정은 `SharedPreferenceManager.role == MemberRole.CREATOR.name` 패턴을 사용한다.
- 검증 기록:
- 2026-07-16: `MainV2ViewModel.getMemberInfo()``SharedPreferenceManager.role`을 확인했고, `HomeFragment`/`LiveFragment` 등 기존 화면이 `MemberRole.CREATOR.name`으로 크리에이터 role을 판정함을 확인했다.
---
### Phase 2: Source 계약 테스트 추가
- [x] **Task 2.1: MainV2 FAB 표시 정책 테스트 추가**
- 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainHomeFabVisibilityPolicy.kt`, `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainHomeFabVisibilityPolicyTest.kt`
- 요구사항:
- `MainHomeFabVisibilityPolicy` 순수 로직으로 홈 탭/로그인/role 조합에 따른 표시 여부를 검증한다.
- UI 레이아웃, constraint, visibility 표현 속성은 테스트하지 않는다.
- 검증 명령:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainHomeFabVisibilityPolicyTest"`
- 검증 기록:
- 2026-07-16: 최초 구현에서 레이아웃 ID/drawable/string/constraint를 문자열로 검사하는 source test를 추가했으나, `docs/agent-guides/code-style.md`의 UI 레이아웃/표현 속성 검증 금지 규칙에 맞지 않아 제거했다.
- 2026-07-16: RED로 `MainHomeFabVisibilityPolicyTest`를 추가했고, 정책 파일 미구현으로 컴파일 실패함을 확인했다. `MainHomeFabVisibilityPolicy` 추가 후 동일 테스트가 PASS했다.
- [x] **Task 2.2: MainV2 FAB 기존 source test 정리**
- 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt`, `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainHomeFabVisibilityPolicyTest.kt`
- 요구사항:
- 신규 레이아웃/visibility source test를 제거한다.
- 표시 조건은 실행 가능한 순수 정책 테스트로 검증한다.
- 기존 `MainV2ActivitySourceTest`의 unrelated source 계약 테스트는 유지한다.
- 검증 명령:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest"`
- 검증 기록:
- 2026-07-16: 최초 구현에서 FAB Activity source test를 추가했으나 실제 동작 실행 없이 source 문자열만 확인한다는 리뷰 지적을 반영해 제거했다. 표시 조건 검증은 `MainHomeFabVisibilityPolicyTest`로 대체했다.
---
### Phase 3: MainV2 FAB UI 구현
- [x] **Task 3.1: activity_main_v2.xml에 FAB overlay 추가**
- 파일: `app/src/main/res/layout/activity_main_v2.xml`
- 구현 내용:
- `owner_fab_dim`에 대응되는 MainV2 전용 dim view를 추가한다.
- expanded container에 community/audio/live/close 버튼을 추가한다.
- collapsed FAB를 추가한다.
- ID는 `main_home_fab_*` 형태로 충돌 없이 명명한다.
- drawable/icon/string은 CreatorChannel 리소스를 재사용한다.
- 검증 기준:
- ViewBinding에서 새 ID들이 생성 가능해야 한다.
- 새 리소스를 만들지 않아야 한다.
- 검증 기록:
- 2026-07-16: `activity_main_v2.xml`에 MainV2 전용 `main_home_fab_*` overlay를 추가하고 CreatorChannel FAB drawable/icon/string 리소스를 재사용했다. `./gradlew :app:assembleDebug`로 ViewBinding/resource 생성이 성공함을 확인했다.
---
### Phase 4: MainV2 FAB 상태/액션 구현
- [x] **Task 4.1: MainV2Activity에 FAB 상태와 애니메이션 추가**
- 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- 구현 내용:
- `isMainHomeFabExpanded`, `isMainHomeFabAnimating` 상태를 추가한다.
- `expandMainHomeFab()`, `collapseMainHomeFab()`, `animateMainHomeFab()`, `updateMainHomeFabVisibility()`를 추가한다.
- `updateMainHomeFabVisibility()``MainV2Tab.HOME`, `isV2AccessAllowed(AccessRequirement.Login)`, `SharedPreferenceManager.role == MemberRole.CREATOR.name` 조건을 모두 만족할 때만 collapsed FAB를 표시한다.
- 홈이 아닌 탭 또는 비크리에이터 상태에서는 확장 상태를 false로 만들고 관련 view를 숨긴다.
- 검증 기준:
- 일반 유저와 비로그인 상태에서는 FAB visibility가 `gone`이어야 한다.
- 검증 기록:
- 2026-07-16: `MainV2Activity``HOME + isV2AccessAllowed(Login) + SharedPreferenceManager.role == MemberRole.CREATOR.name` 표시 조건과 확장/접기 상태를 추가했다. 리뷰어 지적으로 FAB spring 상수를 `CreatorChannelActivity`와 동일한 `260L/1f/256f/24f`로 맞추고 source test에 parity assertion을 추가했다.
- 2026-07-16: source test parity assertion은 UI/source 문자열 테스트 규칙 위반 리뷰를 반영해 제거했고, 표시 조건은 `MainHomeFabVisibilityPolicy` 순수 로직으로 분리했다.
- [x] **Task 4.2: FAB click action 연결**
- 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- 구현 내용:
- collapsed FAB 클릭 시 확장한다.
- dim/close 클릭 시 닫는다.
- community 버튼은 `CreatorCommunityWriteActivity`를 연다.
- audio 버튼은 `AudioContentUploadActivity`를 연다.
- live 버튼은 `LiveRoomCreateActivity``ActivityResultContracts.StartActivityForResult()` launcher로 연다.
- 각 액션은 실행 직전에 FAB를 닫는다.
- 액션 실행 전에도 `ensureV2Access(AccessRequirement.Login)`과 creator role 조건을 확인해 비정상 노출/상태 변경에 대비한다.
- 검증 기준:
- 기존 legacy Activity 파일은 수정하지 않는다.
- 검증 기록:
- 2026-07-16: dim/close/collapsed FAB 클릭과 커뮤니티/오디오/라이브 버튼 클릭을 연결했다. 커뮤니티는 `CreatorCommunityWriteActivity`, 오디오는 `AudioContentUploadActivity`, 라이브는 `LiveRoomCreateActivity` 기존 진입점을 재사용하며 legacy Activity 파일은 수정하지 않았다.
- 2026-07-16: 액션 실행 전 `isV2AccessAllowed()`만 확인해 세션 무효화 시 조용히 무시될 수 있다는 리뷰 지적을 반영했다. `runMainHomeFabAction()`에서 먼저 FAB를 닫고 `ensureV2Access(AccessRequirement.Login)`을 실행한 뒤, 크리에이터 홈 탭 조건이 유지될 때만 기존 생성 진입점을 실행하도록 수정했다.
- [x] **Task 4.3: 생성 결과 후 홈 갱신 연결**
- 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`, `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
- 구현 내용:
- `HomeMainFragment`에 최소 공개 메서드 `refreshHome()`를 추가해 추천 데이터를 다시 로드한다.
- 커뮤니티 작성 완료 및 라이브 생성 결과에서 홈 갱신이 필요한 경우 현재 Home fragment의 `refreshHome()`를 호출한다.
- 라이브 생성 결과가 입장 케이스이면 `LiveActionCoordinator.enterLiveRoom()`를 사용한다.
- 검증 기준:
- Fragment가 없거나 현재 탭이 홈이 아니어도 크래시가 없어야 한다.
- 검증 기록:
- 2026-07-16: `HomeMainFragment.refreshHome()`을 추가해 기존 `homeRecommendationViewModel.loadRecommendations()` 경로를 재사용했다. 커뮤니티 작성 결과와 라이브 생성 결과에서 현재 홈 fragment가 있을 때만 safe-call로 갱신하도록 연결했다.
---
### Phase 5: 검증 및 문서 갱신
- [x] **Task 5.1: 단위/source test 실행**
- 명령:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainHomeFabVisibilityPolicyTest"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.live.action.*"`
- 기대 결과:
- 신규/관련 테스트가 통과한다.
- 검증 기록:
- 2026-07-16: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest"` PASS.
- 2026-07-16: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainHomeFabVisibilityPolicyTest"` PASS.
- 2026-07-16: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.live.action.*"` PASS.
- [x] **Task 5.2: 빌드/정적 검증 실행**
- 명령:
- `./gradlew :app:ktlintCheck`
- `./gradlew :app:assembleDebug`
- `git diff --check`
- 기대 결과:
- 포맷, 빌드, diff whitespace 검증이 통과한다.
- 검증 기록:
- 2026-07-16: `./gradlew :app:ktlintCheck`는 신규 테스트 긴 줄로 1차 실패했고, assertion 포맷 수정 후 PASS했다. 기존 `.editorconfig disabled_rules` deprecation warning은 출력되지만 실패 원인은 아니다.
- 2026-07-16: `./gradlew :app:assembleDebug` PASS.
- 2026-07-16: `git diff --check` PASS.
- 2026-07-16: `lsp_diagnostics``kotlin-ls` 미설치 상태라 실행할 수 없었고, `compileDebugKotlin`/테스트/빌드로 타입 검증을 대체했다.
- [x] **Task 5.3: 수동 QA**
- 확인 항목:
- 비로그인 상태 홈 탭에서 FAB가 표시되지 않는다.
- 일반 유저 로그인 상태 홈 탭에서 FAB가 표시되지 않는다.
- 크리에이터 로그인 상태 홈 탭에서 FAB가 표시된다.
- 크리에이터가 콘텐츠/채팅/마이 탭으로 이동하면 FAB가 숨겨진다.
- FAB 확장, dim 닫기, close 닫기가 `CreatorChannelActivity`와 동일하게 동작한다.
- 커뮤니티/오디오/라이브 버튼이 기존 화면으로 이동한다.
- 미니 플레이어가 표시되어도 FAB가 하단 UI와 과도하게 겹치지 않는다.
- 검증 기록:
- 2026-07-16: 연결 기기 `SM-G960N`이 처음 `adb devices`에 표시되어 `./gradlew :app:installDebug`로 실기기 QA를 시도했으나 APK sync 중 `Broken pipe`로 실패했다. 이어 `adb install -r app/build/outputs/apk/debug/app-debug.apk`를 시도했을 때 `no devices/emulators found`가 발생했고 이후 `adb devices`도 빈 목록이었다. 따라서 실제 화면 캡처 QA는 ADB 연결 문제로 차단되었고, source test/빌드/리뷰어 게이트로 검증을 대체했다.
- 2026-07-16: 리뷰 지적을 반영해 Task 5.3을 완료(`[x]`)가 아닌 `[blocked]` 상태로 정정했다. FAB 위치, 애니메이션, 권한별 노출, 미니 플레이어 중첩은 실제 기기/에뮬레이터 연결 후 별도 수동 QA가 필요하다.
- 2026-07-17: 사용자가 직접 수동 QA를 수행해 비로그인/일반 유저 FAB 미노출, 크리에이터 홈 탭 FAB 노출, 비홈 탭 이동 시 숨김, 확장/닫기 동작, 각 생성 화면 진입, 미니 플레이어 중첩 방지를 확인했다. 액션 실행 직전에 FAB가 닫히는 동작도 확인했다.
## Verification Log
- 2026-07-16: 구현 전 문서 작성 단계. 사용자 확인으로 FAB 표시 대상은 “로그인 후 크리에이터인 경우만”으로 확정했다.
- 2026-07-16: 독립 리뷰어 게이트 1차에서 `CreatorChannelActivity`와 FAB spring 상수가 다르다는 blocker를 받았고, MainV2 상수를 동일하게 수정한 뒤 source test를 확장했다. 재리뷰 결과 PASS, blocker 없음.
- 2026-07-17: 사용자의 직접 수동 QA로 Task 5.3 전체 확인 항목이 통과했으며, 액션 실행 직전 FAB가 닫히는 동작을 포함해 최종 화면 동작을 확인했다.

View File

@@ -0,0 +1,94 @@
# PRD: 메인 V2 홈 FAB
## 1. Overview
`MainV2Activity`의 하단 홈 탭에서 로그인한 크리에이터에게만 콘텐츠 생성 FAB를 제공한다.
---
## 2. Problem
- `CreatorChannelActivity`에는 본인 채널 홈 탭에서 커뮤니티 글 작성, 오디오 콘텐츠 업로드, 라이브 생성을 바로 시작하는 FAB가 있다.
- `MainV2Activity` 홈 탭에는 동일한 빠른 생성 진입점이 없어 크리에이터가 메인 홈에서 생성 기능으로 이동하기 어렵다.
- 일반 유저와 비로그인 유저에게는 생성 권한이 없으므로 FAB를 노출하면 불필요한 진입점이 된다.
---
## 3. Goals
- `MainV2Activity`에서 하단 홈 탭이 선택되어 있고 로그인한 사용자 role이 `CREATOR`일 때만 우측 하단 FAB를 표시한다.
- FAB의 디자인, 확장/닫기 동작, 버튼 구성은 `CreatorChannelActivity`의 홈 탭 FAB와 동일하게 재사용한다.
- FAB 액션은 기존 기능 진입점을 재사용한다.
- 커뮤니티 글 올리기: `CreatorCommunityWriteActivity`
- 오디오 콘텐츠 올리기: `AudioContentUploadActivity`
- 라이브 만들기: `LiveRoomCreateActivity`
- 일반 유저와 비로그인 유저에게 FAB를 표시하지 않는다.
---
## 4. Non-Goals
- `CreatorChannelActivity`의 기존 FAB 디자인/동작을 변경하지 않는다.
- 커뮤니티 글쓰기, 오디오 업로드, 라이브 생성 화면 내부 로직을 변경하지 않는다.
- 새 아이콘, 새 문자열, 새 디자인 시스템을 추가하지 않는다.
- 레거시 화면 파일을 직접 수정하지 않는다.
---
## 5. Target Users
- SodaLive에 로그인한 크리에이터 계정 사용자
---
## 6. User Stories
- 크리에이터는 메인 V2 홈 탭에서 FAB를 눌러 콘텐츠 생성 액션을 빠르게 선택하고 싶다.
- 일반 유저는 생성 권한이 없으므로 메인 홈에서 생성 FAB를 보지 않는다.
- 비로그인 사용자는 메인 홈을 볼 수 있더라도 생성 FAB를 보지 않는다.
---
## 7. Core Features
### Feature A: 메인 홈 크리에이터 FAB 표시
#### Requirements
- `MainV2Activity`의 현재 탭이 `MainV2Tab.HOME`일 때만 표시 후보가 된다.
- 로그인 상태가 아니면 표시하지 않는다.
- `SharedPreferenceManager.role == MemberRole.CREATOR.name`일 때만 표시한다.
- `MainV2Activity.getMemberInfo()`가 role을 갱신한 뒤 FAB visibility를 다시 평가한다.
- 홈 탭이 아닌 탭으로 이동하면 확장 상태를 닫고 FAB를 숨긴다.
#### Edge Cases
- 회원 정보 조회 전 role 기본값이 `USER`이면 FAB를 숨긴다.
- 로그인 세션이 없거나 `ensureV2Access(AccessRequirement.Login)`이 실패하는 상태에서는 FAB를 숨긴다.
- FAB 확장 중 탭이 변경되면 확장 상태를 닫고 숨긴다.
### Feature B: CreatorChannel FAB와 동일한 액션
#### Requirements
- FAB collapsed/expanded/dim/close UI는 `activity_creator_channel.xml`의 owner FAB 구조와 리소스를 재사용한다.
- 버튼 순서는 `CreatorChannelActivity`와 동일하게 커뮤니티, 오디오, 라이브, 닫기 순서로 둔다.
- 각 액션은 실행 직전에 FAB를 닫는다.
- 라이브 생성 결과는 기존 `LiveCreationResult`/`resolveLiveCreationResult`/`LiveActionCoordinator` 패턴을 사용한다.
- 생성 후 홈 데이터 갱신이 필요한 경우 현재 `HomeMainFragment`의 추천 데이터 로드 경로를 최소 범위로 호출한다.
#### Edge Cases
- 미니 플레이어가 표시될 때 FAB와 겹치지 않도록 하단 기준을 `cl_mini_player` 또는 `bottom_navigation` 구조와 함께 검토한다.
- 비홈 탭에서 ActivityResult가 돌아와도 FAB가 다시 표시되지 않아야 한다.
---
## 8. UX / UI Expectations
- 위치, 크기, 배경, 아이콘, dim, 확장 애니메이션은 `CreatorChannelActivity` FAB와 동일한 체감이어야 한다.
- 홈 탭에서만 우측 하단에 노출된다.
- 일반 유저/비로그인 유저에게는 FAB가 전혀 보이지 않는다.
- 접근성용 `contentDescription`은 기존 CreatorChannel 문자열을 재사용한다.
---
## 9. Technical Constraints
- Android Kotlin/ViewBinding 기반 기존 구조를 유지한다.
- `MainV2Activity``activity_main_v2.xml` 중심의 최소 변경으로 구현한다.
- 크리에이터 판정은 기존 코드와 동일하게 `MemberRole.CREATOR.name``SharedPreferenceManager.role`을 사용한다.
- 기존 리소스(`bg_creator_channel_owner_fab`, `bg_creator_channel_owner_fab_live`, `bg_creator_channel_owner_fab_close`, `ic_new_upload_community_post`, `ic_new_upload_audio`, `ic_new_create_live`, `ic_new_x_black`, `creator_channel_owner_fab_*`)를 재사용한다.
---
## 10. Open Questions
- 없음. 사용자 확인으로 FAB 표시 대상은 “로그인 후 크리에이터인 경우만”으로 확정했다.

View File

@@ -90,6 +90,11 @@ Content Action Phase 테스트 예시:
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.content.action.ContentActionTest" ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.content.action.ContentActionTest"
``` ```
Main V2 홈 FAB 표시 정책 테스트 예시:
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainHomeFabVisibilityPolicyTest"
```
Live Action Phase 테스트 예시: Live Action Phase 테스트 예시:
```bash ```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.live.action.*" ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.live.action.*"