From e90fb04de9394fe7a2d140deb637c965a8f8e810 Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 17 Jun 2026 23:24:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=ED=83=AD=20=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creator/channel/CreatorChannelActivity.kt | 80 ++++++++++++++++++- .../channel/CreatorChannelPagerAdapter.kt | 2 + .../CreatorChannelActivitySourceTest.kt | 58 +++++++++++++- 3 files changed, 133 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 7ac62d1a..e0300799 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 @@ -55,6 +55,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelAudioConte import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelScheduleResponse import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelSeriesResponse +import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveFragment import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelHeaderUiModel import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelScrollState import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab @@ -67,7 +68,8 @@ import org.koin.android.ext.android.inject class CreatorChannelActivity : BaseActivity(ActivityCreatorChannelBinding::inflate), - CreatorChannelHomeFragment.Host { + CreatorChannelHomeFragment.Host, + CreatorChannelLiveFragment.Host { private val liveViewModel: LiveViewModel by inject() private val myPageViewModel: MyPageViewModel by inject() @@ -262,8 +264,25 @@ class CreatorChannelActivity : } private fun setupScrollListener() { - binding.nestedScrollView.setOnScrollChangeListener { _, _, scrollY, _, _ -> + binding.nestedScrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> updateScrollState(scrollY) + onCreatorChannelNestedScrollChanged(scrollY, oldScrollY) + } + } + + private fun onCreatorChannelNestedScrollChanged(scrollY: Int, oldScrollY: Int) { + if (binding.viewPager.currentItem != CreatorChannelTab.Live.ordinal) return + if (scrollY <= oldScrollY) return + + val contentHeight = binding.nestedScrollView.getChildAt(0)?.height ?: return + val threshold = CREATOR_CHANNEL_LIVE_LOAD_MORE_THRESHOLD_DP.dpToPx().toInt() + val remainingScroll = calculateCreatorChannelLiveRemainingScroll( + contentHeight = contentHeight, + viewportHeight = binding.nestedScrollView.height, + scrollY = scrollY + ) + if (remainingScroll <= threshold) { + findLiveFragment()?.onCreatorChannelLiveScrolledToBottom() } } @@ -352,6 +371,11 @@ class CreatorChannelActivity : } updateOwnerFabVisibility() updateViewPagerHeight() + if (position == CreatorChannelTab.Live.ordinal) { + binding.viewPager.post { + findLiveFragment()?.onCreatorChannelLiveTabSelected() + } + } } } pageChangeCallback = callback @@ -396,6 +420,11 @@ class CreatorChannelActivity : updateViewPagerHeight() } + override fun onCreatorChannelLiveContentChanged() { + updateViewPagerHeight() + postCheckCreatorChannelLiveNeedsMore() + } + private fun setupOwnerFabInsets() { ViewCompat.setOnApplyWindowInsetsListener(binding.ownerFabButton) { _, insets -> val navigationBottomInset = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom @@ -523,6 +552,15 @@ class CreatorChannelActivity : } } + override fun onCreatorChannelLiveReplayClicked(audioContentId: Long) { + startAudioContentDetail(audioContentId) + } + + private fun findLiveFragment(): CreatorChannelLiveFragment? { + val fragmentTag = "f${CreatorChannelTab.Live.ordinal}" + return supportFragmentManager.findFragmentByTag(fragmentTag) as? CreatorChannelLiveFragment + } + private fun ensureLoginAndAdultAuth(isAdult: Boolean, onAuthed: () -> Unit) { if (SharedPreferenceManager.token.isBlank()) { showLoginActivity() @@ -592,6 +630,7 @@ class CreatorChannelActivity : binding.viewPager.post { val recyclerView = binding.viewPager.getChildAt(0) as? RecyclerView ?: return@post val currentPage = recyclerView.layoutManager?.findViewByPosition(binding.viewPager.currentItem) ?: return@post + currentPage.minimumHeight = calculateCreatorChannelTabViewportHeight() val widthSpec = MeasureSpec.makeMeasureSpec(binding.viewPager.width, MeasureSpec.EXACTLY) val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) currentPage.measure(widthSpec, heightSpec) @@ -604,6 +643,30 @@ class CreatorChannelActivity : } } + private fun postCheckCreatorChannelLiveNeedsMore() { + binding.nestedScrollView.post { + checkCreatorChannelLiveNeedsMore() + } + } + + private fun checkCreatorChannelLiveNeedsMore() { + if (binding.viewPager.currentItem != CreatorChannelTab.Live.ordinal) return + + val contentHeight = binding.nestedScrollView.getChildAt(0)?.height ?: return + val remainingScroll = calculateCreatorChannelLiveRemainingScroll( + contentHeight = contentHeight, + viewportHeight = binding.nestedScrollView.height, + scrollY = binding.nestedScrollView.scrollY + ) + if (remainingScroll <= 0) { + findLiveFragment()?.onCreatorChannelLiveScrolledToBottom() + } + } + + private fun calculateCreatorChannelTabViewportHeight(): Int { + return (binding.nestedScrollView.height - binding.tabLayout.height).coerceAtLeast(0) + } + private fun onScheduleClicked(schedule: CreatorChannelScheduleResponse) { when (schedule.type) { CreatorActivityType.Audio, @@ -620,9 +683,13 @@ class CreatorChannelActivity : } private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse) { + startAudioContentDetail(audioContent.audioContentId) + } + + private fun startAudioContentDetail(audioContentId: Long) { startActivity( Intent(this, AudioContentDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContent.audioContentId) + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId) } ) } @@ -671,6 +738,7 @@ class CreatorChannelActivity : private const val OWNER_FAB_SPRING_MASS = 1f private const val OWNER_FAB_SPRING_STIFFNESS = 256f private const val OWNER_FAB_SPRING_DAMPING = 24f + private const val CREATOR_CHANNEL_LIVE_LOAD_MORE_THRESHOLD_DP = 200 fun newIntent(context: Context, creatorId: Long): Intent { return Intent(context, CreatorChannelActivity::class.java).apply { @@ -679,3 +747,9 @@ class CreatorChannelActivity : } } } + +internal fun calculateCreatorChannelLiveRemainingScroll( + contentHeight: Int, + viewportHeight: Int, + scrollY: Int +): Int = contentHeight - viewportHeight - scrollY diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPagerAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPagerAdapter.kt index 32bcebae..bdddf8dd 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPagerAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPagerAdapter.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter +import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveFragment import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab class CreatorChannelPagerAdapter( @@ -17,6 +18,7 @@ class CreatorChannelPagerAdapter( val tab = tabs[position] return when (tab) { CreatorChannelTab.Home -> CreatorChannelHomeFragment.newInstance(creatorId) + CreatorChannelTab.Live -> CreatorChannelLiveFragment.newInstance(creatorId) else -> CreatorChannelPlaceholderFragment.newInstance(tab) } } 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 3d4f1a7d..e263b46a 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 @@ -330,7 +330,7 @@ class CreatorChannelActivitySourceTest { } @Test - fun `pager adapter source는 7개 탭 순서와 홈 placeholder Fragment를 연결한다`() { + fun `pager adapter source는 홈과 라이브를 실제 Fragment로 연결하고 후속 탭은 placeholder로 유지한다`() { val adapter = projectFile( "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPagerAdapter.kt" ).readText() @@ -346,6 +346,7 @@ class CreatorChannelActivitySourceTest { assertTrue(adapter.contains("private val tabs: List = CreatorChannelTab.entries")) assertTrue(adapter.contains("override fun getItemCount(): Int = tabs.size")) assertTrue(adapter.contains("CreatorChannelTab.Home -> CreatorChannelHomeFragment.newInstance(creatorId)")) + assertTrue(adapter.contains("CreatorChannelTab.Live -> CreatorChannelLiveFragment.newInstance(creatorId)")) assertTrue(adapter.contains("else -> CreatorChannelPlaceholderFragment.newInstance(tab)")) assertTrue(placeholder.contains("private const val ARG_TAB_NAME")) assertTrue(placeholder.contains("fun newInstance(tab: CreatorChannelTab): CreatorChannelPlaceholderFragment")) @@ -368,7 +369,6 @@ class CreatorChannelActivitySourceTest { assertTrue(source.contains("tab.text = getString(CreatorChannelTab.entries[position].labelResId)")) assertTrue(source.contains(".attach()")) assertTrue(source.contains("binding.viewPager.isUserInputEnabled = true")) - assertTrue(source.contains("binding.viewPager.offscreenPageLimit = CreatorChannelTab.entries.size - 1")) assertTrue(source.contains("binding.viewPager.registerOnPageChangeCallback(callback)")) assertTrue(source.contains("override fun onDestroy()")) assertTrue(source.contains("tabLayoutMediator?.detach()")) @@ -377,6 +377,7 @@ class CreatorChannelActivitySourceTest { assertTrue(source.contains("findViewByPosition(binding.viewPager.currentItem)")) assertTrue(source.contains("currentPage.measure(widthSpec, heightSpec)")) assertTrue(source.contains("binding.viewPager.updateLayoutParams")) + assertTrue(source.contains("binding.viewPager.offscreenPageLimit = CreatorChannelTab.entries.size - 1")) assertFalse(source.contains("binding.tabLayout.addTab")) assertFalse(source.contains("private fun createTabView")) } @@ -391,7 +392,6 @@ class CreatorChannelActivitySourceTest { assertFalse(source.contains("setOnClickListener { onTabClicked(tab) }")) assertFalse(source.contains("private fun onTabClicked(tab: CreatorChannelTab)")) assertFalse(source.contains("if (tab != CreatorChannelTab.Home) return")) - assertFalse(source.contains("CreatorChannelTab.Live ->")) assertFalse(source.contains("CreatorChannelTab.Audio ->")) assertFalse(source.contains("CreatorChannelTab.Series ->")) assertFalse(source.contains("CreatorChannelTab.Community ->")) @@ -913,6 +913,55 @@ class CreatorChannelActivitySourceTest { assertEquals("2026.06.30 00:00:01", formatCreatorChannelLiveDateTime("2026-06-29T15:00:01Z", timeZone, locale)) } + @Test + fun `라이브 탭 pagination과 높이 갱신은 NestedScrollView 소유 스크롤 경로에서 처리한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" + ).readText() + val fragment = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragment.kt" + ).readText() + + assertTrue(source.contains("private fun onCreatorChannelNestedScrollChanged(scrollY: Int, oldScrollY: Int)")) + assertTrue(source.contains("binding.nestedScrollView.getChildAt(0)?.height")) + assertTrue(source.contains("binding.nestedScrollView.height")) + assertTrue(source.contains("CREATOR_CHANNEL_LIVE_LOAD_MORE_THRESHOLD_DP.dpToPx().toInt()")) + assertTrue(source.contains("findLiveFragment()?.onCreatorChannelLiveScrolledToBottom()")) + assertTrue(source.contains("private fun findLiveFragment(): CreatorChannelLiveFragment?")) + assertTrue(source.contains("supportFragmentManager.findFragmentByTag")) + assertTrue(source.contains("override fun onCreatorChannelLiveContentChanged()")) + assertTrue(source.contains("postCheckCreatorChannelLiveNeedsMore()")) + assertTrue(source.contains("updateViewPagerHeight()")) + assertTrue(source.contains("if (position == CreatorChannelTab.Live.ordinal)")) + assertTrue(source.contains("findLiveFragment()?.onCreatorChannelLiveTabSelected()")) + assertTrue(source.contains("binding.viewPager.offscreenPageLimit = CreatorChannelTab.entries.size - 1")) + assertTrue(source.contains("currentPage.minimumHeight = calculateCreatorChannelTabViewportHeight()")) + assertTrue(source.contains("private fun calculateCreatorChannelTabViewportHeight(): Int")) + assertTrue(fragment.contains("fun onCreatorChannelLiveScrolledToBottom()")) + assertTrue(fragment.contains("fun onCreatorChannelLiveTabSelected()")) + assertTrue(fragment.contains("fun onCreatorChannelLiveContentChanged()")) + } + + @Test + fun `라이브 content 변경은 현재 scroll bottom 조건을 재평가한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" + ).readText() + + assertTrue(source.contains("private fun postCheckCreatorChannelLiveNeedsMore()")) + assertTrue(source.contains("binding.nestedScrollView.post")) + assertTrue(source.contains("checkCreatorChannelLiveNeedsMore()")) + assertTrue(source.contains("if (binding.viewPager.currentItem != CreatorChannelTab.Live.ordinal) return")) + assertTrue(source.contains("findLiveFragment()?.onCreatorChannelLiveScrolledToBottom()")) + } + + @Test + fun `라이브 pagination remaining scroll helper는 content viewport scroll 차이를 계산한다`() { + assertEquals(100, calculateCreatorChannelLiveRemainingScroll(contentHeight = 1000, viewportHeight = 700, scrollY = 200)) + assertEquals(0, calculateCreatorChannelLiveRemainingScroll(contentHeight = 1000, viewportHeight = 700, scrollY = 300)) + assertEquals(-20, calculateCreatorChannelLiveRemainingScroll(contentHeight = 1000, viewportHeight = 700, scrollY = 320)) + } + @Test fun `owner FAB 라이브 생성 결과는 기존 enterLiveRoom 플로우로 입장한다`() { val source = projectFile( @@ -1144,7 +1193,8 @@ class CreatorChannelActivitySourceTest { assertTrue(fragment.contains("host.onCreatorChannelAudioContentClicked(audioContent)")) assertTrue(source.contains("private fun onAudioContentClicked(audioContent: CreatorChannelAudioContentResponse)")) assertTrue(source.contains("AudioContentDetailActivity::class.java")) - assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContent.audioContentId)")) + assertTrue(source.contains("startAudioContentDetail(audioContent.audioContentId)")) + assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId)")) } @Test