From 5d52787ea923b37603306184b0a39712b7d339a3 Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 16 Jun 2026 22:24:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EB=B3=B8=EC=9D=B8=20FAB=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=EC=9D=84=20=EC=97=B0=EA=B2=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creator/channel/CreatorChannelActivity.kt | 136 +++++++++++++++++- .../CreatorChannelActivitySourceTest.kt | 55 ++++++- 2 files changed, 184 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt index ee65d6c6..4488ecae 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt @@ -1,13 +1,18 @@ package kr.co.vividnext.sodalive.v2.creator.channel +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator import android.content.Context import android.content.Intent import android.graphics.Color +import android.view.animation.Interpolator import android.view.LayoutInflater import android.view.View import android.view.View.MeasureSpec import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -20,6 +25,9 @@ import com.google.android.material.tabs.TabLayoutMediator import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity +import kr.co.vividnext.sodalive.live.room.create.LiveRoomCreateActivity +import kr.co.vividnext.sodalive.explorer.profile.creator_community.write.CreatorCommunityWriteActivity +import kr.co.vividnext.sodalive.audio_content.upload.AudioContentUploadActivity import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomActivity import kr.co.vividnext.sodalive.common.Constants @@ -54,6 +62,7 @@ class CreatorChannelActivity : private var tabLayoutMediator: TabLayoutMediator? = null private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null private var isOwnerFabExpanded: Boolean = false + private var isOwnerFabAnimating: Boolean = false private val baseTitleBarHeight: Int by lazy { 60.dpToPx().toInt() } override val shouldApplySystemBarTopInset: Boolean = false @@ -68,6 +77,7 @@ class CreatorChannelActivity : setupTabsAndPager() setStatusBarIconAppearance() setTitleBarTopInset() + setupOwnerFabInsets() setupScrollListener() setupClickListeners() } @@ -80,6 +90,9 @@ class CreatorChannelActivity : binding.ownerFabButton.setOnClickListener { expandOwnerFab() } binding.ownerFabDim.setOnClickListener { collapseOwnerFab() } binding.ownerFabCloseButton.setOnClickListener { collapseOwnerFab() } + binding.ownerFabCommunityButton.setOnClickListener { onOwnerFabCommunityClicked() } + binding.ownerFabAudioButton.setOnClickListener { onOwnerFabAudioClicked() } + binding.ownerFabLiveButton.setOnClickListener { onOwnerFabLiveClicked() } binding.tvChatButton.setOnClickListener { currentHeader?.characterId?.let { characterId -> homeActionDelegate?.createChatRoom(characterId) } } @@ -271,7 +284,7 @@ class CreatorChannelActivity : val callback = object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { if (position != CreatorChannelTab.Home.ordinal) { - isOwnerFabExpanded = false + collapseOwnerFab(animate = false) } updateOwnerFabVisibility() updateViewPagerHeight() @@ -319,14 +332,75 @@ class CreatorChannelActivity : updateViewPagerHeight() } - private fun expandOwnerFab() { - isOwnerFabExpanded = true - updateOwnerFabVisibility() + private fun setupOwnerFabInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.ownerFabButton) { _, insets -> + val navigationBottomInset = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom + val bottomMargin = OWNER_FAB_BASE_MARGIN_DP.dpToPx().toInt() + navigationBottomInset + binding.ownerFabButton.updateLayoutParams { + this.bottomMargin = bottomMargin + } + binding.ownerFabExpandedContainer.updateLayoutParams { + this.bottomMargin = bottomMargin + } + binding.viewPager.updatePadding(bottom = OWNER_FAB_CONTENT_BOTTOM_PADDING_DP.dpToPx().toInt()) + insets + } + ViewCompat.requestApplyInsets(binding.ownerFabButton) } - private fun collapseOwnerFab() { + private fun expandOwnerFab() { + if (isOwnerFabAnimating) return + if (isOwnerFabExpanded) return + + isOwnerFabExpanded = true + animateOwnerFab(expand = true) + } + + private fun collapseOwnerFab(animate: Boolean = true) { + if (isOwnerFabAnimating) return + if (!isOwnerFabExpanded) { + updateOwnerFabVisibility() + return + } + isOwnerFabExpanded = false - updateOwnerFabVisibility() + if (animate) { + animateOwnerFab(expand = false) + } else { + updateOwnerFabVisibility() + } + } + + private fun animateOwnerFab(expand: Boolean) { + isOwnerFabAnimating = true + binding.ownerFabDim.isVisible = true + binding.ownerFabExpandedContainer.isVisible = true + binding.ownerFabButton.isVisible = true + val start = if (expand) 0f else 1f + val end = if (expand) 1f else 0f + ValueAnimator.ofFloat(start, end).apply { + duration = OWNER_FAB_ANIMATION_DURATION_MS + interpolator = SpringInterpolator( + mass = OWNER_FAB_SPRING_MASS, + stiffness = OWNER_FAB_SPRING_STIFFNESS, + damping = OWNER_FAB_SPRING_DAMPING + ) + addUpdateListener { animator -> + val value = animator.animatedValue as Float + binding.ownerFabDim.alpha = value + binding.ownerFabExpandedContainer.alpha = value + binding.ownerFabExpandedContainer.scaleX = value + binding.ownerFabExpandedContainer.scaleY = value + binding.ownerFabButton.alpha = 1f - value + } + addListener( + onEnd = { + isOwnerFabAnimating = false + updateOwnerFabVisibility() + } + ) + start() + } } private fun updateOwnerFabVisibility() { @@ -335,6 +409,31 @@ class CreatorChannelActivity : binding.ownerFabDim.isVisible = shouldShowOwnerFab && isOwnerFabExpanded binding.ownerFabExpandedContainer.isVisible = shouldShowOwnerFab && isOwnerFabExpanded binding.ownerFabButton.isVisible = shouldShowOwnerFab && !isOwnerFabExpanded + if (!shouldShowOwnerFab) { + isOwnerFabExpanded = false + } + if (!binding.ownerFabExpandedContainer.isVisible) { + binding.ownerFabDim.alpha = 1f + binding.ownerFabExpandedContainer.alpha = 1f + binding.ownerFabExpandedContainer.scaleX = 1f + binding.ownerFabExpandedContainer.scaleY = 1f + binding.ownerFabButton.alpha = 1f + } + } + + private fun onOwnerFabCommunityClicked() { + collapseOwnerFab(animate = false) + startActivity(Intent(this, CreatorCommunityWriteActivity::class.java)) + } + + private fun onOwnerFabAudioClicked() { + collapseOwnerFab(animate = false) + startActivity(Intent(this, AudioContentUploadActivity::class.java)) + } + + private fun onOwnerFabLiveClicked() { + collapseOwnerFab(animate = false) + startActivity(Intent(this, LiveRoomCreateActivity::class.java)) } override fun onCreatorChannelDonationClicked() { @@ -424,8 +523,33 @@ class CreatorChannelActivity : super.onDestroy() } + 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 { const val EXTRA_CREATOR_ID: String = "extra_creator_id" + private const val OWNER_FAB_BASE_MARGIN_DP = 14 + private const val OWNER_FAB_CONTENT_BOTTOM_PADDING_DP = 96 + private const val OWNER_FAB_ANIMATION_DURATION_MS = 260L + private const val OWNER_FAB_SPRING_MASS = 1f + private const val OWNER_FAB_SPRING_STIFFNESS = 256f + private const val OWNER_FAB_SPRING_DAMPING = 24f fun newIntent(context: Context, creatorId: Long): Intent { return Intent(context, CreatorChannelActivity::class.java).apply { diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt index d700fe75..c41c1561 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt @@ -1415,7 +1415,11 @@ class CreatorChannelActivitySourceTest { assertTrue(strings.contains("name=\"creator_channel_owner_fab_close\">닫기")) assertTrue(source.contains("private var isOwnerFabExpanded: Boolean = false")) assertTrue(source.contains("updateOwnerFabVisibility()")) - assertTrue(source.contains("currentHeader?.isOwner == true && binding.viewPager.currentItem == CreatorChannelTab.Home.ordinal")) + assertTrue( + source.contains( + "currentHeader?.isOwner == true && binding.viewPager.currentItem == CreatorChannelTab.Home.ordinal" + ) + ) assertTrue(source.contains("binding.ownerFabDim.setOnClickListener { collapseOwnerFab() }")) assertTrue(source.contains("binding.ownerFabCloseButton.setOnClickListener { collapseOwnerFab() }")) assertTrue(source.contains("binding.ownerFabButton.setOnClickListener { expandOwnerFab() }")) @@ -1424,6 +1428,55 @@ class CreatorChannelActivitySourceTest { assertTrue(source.contains("binding.ownerFabButton.isVisible = shouldShowOwnerFab && !isOwnerFabExpanded")) } + @Test + fun `Phase 13 owner FAB source는 spring animation과 navigation inset을 적용한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" + ).readText() + + assertTrue(source.contains("private var isOwnerFabAnimating: Boolean = false")) + assertTrue(source.contains("setupOwnerFabInsets()")) + assertTrue(source.contains("WindowInsetsCompat.Type.navigationBars()")) + assertTrue(source.contains("OWNER_FAB_BASE_MARGIN_DP.dpToPx().toInt() + navigationBottomInset")) + assertTrue(source.contains("binding.ownerFabButton.updateLayoutParams")) + assertTrue(source.contains("binding.ownerFabExpandedContainer.updateLayoutParams")) + assertTrue( + source.contains( + "binding.viewPager.updatePadding(bottom = OWNER_FAB_CONTENT_BOTTOM_PADDING_DP.dpToPx().toInt())" + ) + ) + assertTrue(source.contains("ValueAnimator.ofFloat")) + assertTrue(source.contains("SpringInterpolator(")) + assertTrue(source.contains("mass = OWNER_FAB_SPRING_MASS")) + assertTrue(source.contains("stiffness = OWNER_FAB_SPRING_STIFFNESS")) + assertTrue(source.contains("damping = OWNER_FAB_SPRING_DAMPING")) + assertTrue(source.contains("if (isOwnerFabAnimating) return")) + assertTrue(source.contains("duration = OWNER_FAB_ANIMATION_DURATION_MS")) + } + + @Test + fun `Phase 13 owner FAB source는 3개 액션 진입점을 연결하고 클릭 후 닫는다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" + ).readText() + + assertTrue(source.contains("CreatorCommunityWriteActivity")) + assertTrue(source.contains("AudioContentUploadActivity")) + assertTrue(source.contains("LiveRoomCreateActivity")) + assertTrue( + source.contains("binding.ownerFabCommunityButton.setOnClickListener { onOwnerFabCommunityClicked() }") + ) + assertTrue(source.contains("binding.ownerFabAudioButton.setOnClickListener { onOwnerFabAudioClicked() }")) + assertTrue(source.contains("binding.ownerFabLiveButton.setOnClickListener { onOwnerFabLiveClicked() }")) + assertTrue(source.contains("private fun onOwnerFabCommunityClicked()")) + assertTrue(source.contains("private fun onOwnerFabAudioClicked()")) + assertTrue(source.contains("private fun onOwnerFabLiveClicked()")) + assertTrue(source.contains("startActivity(Intent(this, CreatorCommunityWriteActivity::class.java))")) + assertTrue(source.contains("startActivity(Intent(this, AudioContentUploadActivity::class.java))")) + assertTrue(source.contains("startActivity(Intent(this, LiveRoomCreateActivity::class.java))")) + assertTrue(source.contains("collapseOwnerFab(animate = false)")) + } + @Test fun `남은 section item layouts는 legacy generic card id를 제거한다`() { val layoutNames = listOf(