feat(main): 홈 크리에이터 FAB를 추가한다
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
notificationSettingsDialog.show(screenWidth)
|
context = applicationContext,
|
||||||
}
|
showNotificationSettingsDialog = {
|
||||||
|
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"
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{}
|
{}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
169
docs/20260716_메인_V2_홈_FAB/plan-task.md
Normal file
169
docs/20260716_메인_V2_홈_FAB/plan-task.md
Normal 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가 닫히는 동작을 포함해 최종 화면 동작을 확인했다.
|
||||||
94
docs/20260716_메인_V2_홈_FAB/prd.md
Normal file
94
docs/20260716_메인_V2_홈_FAB/prd.md
Normal 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 표시 대상은 “로그인 후 크리에이터인 경우만”으로 확정했다.
|
||||||
@@ -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.*"
|
||||||
|
|||||||
Reference in New Issue
Block a user