From 5d66014044ee0b2cccad80f7d9cf54eef6d5c7aa Mon Sep 17 00:00:00 2001 From: klaus Date: Mon, 8 Jun 2026 18:07:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EB=9E=AD=ED=82=B9=20=ED=83=AD=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=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 --- .../sodalive/v2/main/HomeMainFragment.kt | 67 ++++++++++++- .../main/res/layout/fragment_v2_main_home.xml | 15 +++ .../main/home/HomeMainFragmentLayoutTest.kt | 97 +++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt index fae2537a..821e5b2a 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/HomeMainFragment.kt @@ -16,6 +16,7 @@ import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding import kr.co.vividnext.sodalive.explorer.profile.UserProfileActivity import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity +import kr.co.vividnext.sodalive.v2.main.home.HomeCreatorRankingViewModel import kr.co.vividnext.sodalive.v2.main.home.HomeRecommendationViewModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationAiCharacterSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationBannerSection @@ -32,6 +33,7 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularComm import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentlyActiveCreatorUiModel import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationRecentDebutCreatorSection +import kr.co.vividnext.sodalive.v2.main.home.model.HomeCreatorRankingUiState import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationUiState import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerIntent import kr.co.vividnext.sodalive.v2.main.home.model.toHomeRecommendationBannerRoute @@ -48,6 +50,8 @@ import kr.co.vividnext.sodalive.v2.main.home.ui.HomeLiveAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomePopularCommunityAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeRecentActivityCreatorAdapter import kr.co.vividnext.sodalive.v2.main.home.ui.HomeRecentDebutCreatorAdapter +import kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingAdapter +import kr.co.vividnext.sodalive.v2.widget.creatorranking.CreatorRankingItem import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem import org.koin.androidx.viewmodel.ext.android.viewModel @@ -55,6 +59,7 @@ class HomeMainFragment : BaseFragment( FragmentV2MainHomeBinding::inflate ) { private val homeRecommendationViewModel: HomeRecommendationViewModel by viewModel() + private val homeCreatorRankingViewModel: HomeCreatorRankingViewModel by viewModel() private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) } private val liveAdapter = HomeLiveAdapter() private val recentActivityCreatorAdapter = HomeRecentActivityCreatorAdapter { onRecentActivityClick(it) } @@ -70,9 +75,11 @@ class HomeMainFragment : BaseFragment( onCreatorClick = { creator -> openCreatorProfile(creator.creatorId) } ) private val popularCommunityAdapter = HomePopularCommunityAdapter { openPopularCommunityPost(it) } + private val creatorRankingAdapter = CreatorRankingAdapter { openCreatorRankingProfile(it) } private var bannerBinder: HomeBannerBinder? = null private var onGenreFollowAllClick: (List) -> Unit = {} private var onCheerFollowAllClick: (List) -> Unit = {} + private var hasLoadedCreatorRankings = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -84,11 +91,15 @@ class HomeMainFragment : BaseFragment( ), selectedIndex = 0 ) - binding.textTabBarHome.root.setOnTabSelectedListener { } + binding.textTabBarHome.root.setOnTabSelectedListener { index -> + showHomeTab(index) + } setUpSectionTitles() setUpRecommendationAdapters() + setUpCreatorRankingAdapter() setUpBusinessInfo() bindHomeRecommendationObservers() + bindHomeCreatorRankingObservers() homeRecommendationViewModel.loadRecommendations() } @@ -141,6 +152,52 @@ class HomeMainFragment : BaseFragment( } } + private fun setUpCreatorRankingAdapter() { + binding.rvHomeCreatorRankings.apply { + layoutManager = CreatorRankingAdapter.createGridLayoutManager(requireContext()) + adapter = creatorRankingAdapter + } + } + + private fun bindHomeCreatorRankingObservers() { + homeCreatorRankingViewModel.rankingStateLiveData.observe(viewLifecycleOwner) { state -> + when (state) { + is HomeCreatorRankingUiState.Content -> creatorRankingAdapter.submitItems(state.items) + HomeCreatorRankingUiState.Empty, + is HomeCreatorRankingUiState.Error -> creatorRankingAdapter.submitItems(emptyList()) + HomeCreatorRankingUiState.Loading -> Unit + } + } + homeCreatorRankingViewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + if (isLoading) { + loadingDialog.show(screenWidth) + } else { + loadingDialog.dismiss() + } + } + homeCreatorRankingViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> + toastMessage?.let(::showToast) + } + } + + private fun showHomeTab(index: Int) { + when (index) { + HOME_TAB_RECOMMENDATION -> { + binding.nsvHomeRecommendationContent.visibility = View.VISIBLE + binding.rvHomeCreatorRankings.visibility = View.GONE + } + HOME_TAB_RANKING -> { + binding.nsvHomeRecommendationContent.visibility = View.GONE + binding.rvHomeCreatorRankings.visibility = View.VISIBLE + if (!hasLoadedCreatorRankings) { + hasLoadedCreatorRankings = true + homeCreatorRankingViewModel.loadCreatorRankings() + } + } + HOME_TAB_FOLLOWING -> Unit + } + } + private fun bindHomeRecommendationObservers() { homeRecommendationViewModel.recommendationStateLiveData.observe(viewLifecycleOwner) { state -> when (state) { @@ -280,6 +337,11 @@ class HomeMainFragment : BaseFragment( startActivity(route.toHomeRecommendationRecentlyActiveCreatorIntent(requireContext())) } + private fun openCreatorRankingProfile(item: CreatorRankingItem) { + if (item.creatorId <= 0L) return + openCreatorProfile(item.creatorId) + } + private fun openCreatorProfile(creatorId: Long) { startActivity( Intent(requireContext(), UserProfileActivity::class.java).apply { @@ -351,6 +413,9 @@ class HomeMainFragment : BaseFragment( private fun List<*>.toSectionVisibility(): Int = if (isEmpty()) View.GONE else View.VISIBLE private companion object { + const val HOME_TAB_RECOMMENDATION = 0 + const val HOME_TAB_RANKING = 1 + const val HOME_TAB_FOLLOWING = 2 const val SECTION_KEY_CHEER_CREATORS = "cheerCreators" const val SECTION_KEY_GENRE_CREATORS = "genreCreators" } diff --git a/app/src/main/res/layout/fragment_v2_main_home.xml b/app/src/main/res/layout/fragment_v2_main_home.xml index 5073559b..8acfaae4 100644 --- a/app/src/main/res/layout/fragment_v2_main_home.xml +++ b/app/src/main/res/layout/fragment_v2_main_home.xml @@ -238,4 +238,19 @@ + + diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt index b0579d9c..5262cce8 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt @@ -697,6 +697,38 @@ class HomeMainFragmentLayoutTest { assertEquals(expectedFreeTag.paddingStart, freeTag.paddingStart) } + @Test + fun `home ranking layout contains ranking list below text tab bar`() { + val root = inflateView(R.layout.fragment_v2_main_home) + val rankingList = root.findViewById(R.id.rv_home_creator_rankings) + val layoutParams = rankingList.layoutParams as ConstraintLayout.LayoutParams + + assertNotNull(rankingList) + assertSame(root, rankingList.parent) + assertEquals(0, layoutParams.width) + assertEquals(0, layoutParams.height) + assertEquals(R.id.text_tab_bar_home, layoutParams.topToBottom) + assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, layoutParams.startToStart) + assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, layoutParams.endToEnd) + assertEquals(ConstraintLayout.LayoutParams.PARENT_ID, layoutParams.bottomToBottom) + assertEquals(View.GONE, rankingList.visibility) + assertEquals(false, rankingList.clipToPadding) + assertEquals(14.dpToPx(), rankingList.paddingStart) + assertEquals(14.dpToPx(), rankingList.paddingTop) + assertEquals(28.dpToPx(), rankingList.paddingBottom) + } + + @Test + fun `home ranking layout does not add capsule tab bar`() { + val root = inflateView(R.layout.fragment_v2_main_home) + val layoutSource = homeMainLayoutSource() + + assertFalse(root.containsClassName("kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView")) + assertFalse(layoutSource.contains("view_capsule_tab_bar")) + assertFalse(layoutSource.contains("hsv_capsule_tab_bar")) + assertFalse(layoutSource.contains("ll_capsule_tab_container")) + } + @Test fun `popular community section is hidden until phase7 binding is implemented`() { val root = inflateView(R.layout.fragment_v2_main_home) @@ -1264,6 +1296,63 @@ class HomeMainFragmentLayoutTest { } } + @Test + fun `home ranking fragment wires adapter and grid layout manager`() { + val source = homeMainFragmentSource() + + assertTrue(source.contains("HomeCreatorRankingViewModel")) + assertTrue(source.contains("CreatorRankingAdapter { openCreatorRankingProfile(it) }")) + assertTrue(source.contains("binding.rvHomeCreatorRankings.apply")) + assertTrue(source.contains("layoutManager = CreatorRankingAdapter.createGridLayoutManager(requireContext())")) + assertTrue(source.contains("adapter = creatorRankingAdapter")) + } + + @Test + fun `home ranking fragment switches recommendation and ranking content by tab`() { + val source = homeMainFragmentSource() + + assertTrue(source.contains("HOME_TAB_RECOMMENDATION = 0")) + assertTrue(source.contains("HOME_TAB_RANKING = 1")) + assertTrue(source.contains("HOME_TAB_FOLLOWING = 2")) + assertTrue(source.contains("binding.textTabBarHome.root.setOnTabSelectedListener { index ->")) + assertTrue(source.contains("showHomeTab(index)")) + assertTrue(source.contains("binding.nsvHomeRecommendationContent.visibility = View.GONE")) + assertTrue(source.contains("binding.rvHomeCreatorRankings.visibility = View.VISIBLE")) + assertTrue(source.contains("binding.nsvHomeRecommendationContent.visibility = View.VISIBLE")) + assertTrue(source.contains("binding.rvHomeCreatorRankings.visibility = View.GONE")) + } + + @Test + fun `home ranking fragment loads rankings once on first ranking selection`() { + val source = homeMainFragmentSource() + + assertTrue(source.contains("private var hasLoadedCreatorRankings = false")) + assertTrue(source.contains("if (!hasLoadedCreatorRankings)")) + assertTrue(source.contains("hasLoadedCreatorRankings = true")) + assertTrue(source.contains("homeCreatorRankingViewModel.loadCreatorRankings()")) + } + + @Test + fun `home ranking fragment observes ranking state and submits items`() { + val source = homeMainFragmentSource() + + assertTrue(source.contains("rankingStateLiveData.observe(viewLifecycleOwner)")) + assertTrue(source.contains("is HomeCreatorRankingUiState.Content -> creatorRankingAdapter.submitItems(state.items)")) + assertTrue(source.contains("HomeCreatorRankingUiState.Empty,")) + assertTrue(source.contains("is HomeCreatorRankingUiState.Error -> creatorRankingAdapter.submitItems(emptyList())")) + assertTrue(source.contains("HomeCreatorRankingUiState.Loading -> Unit")) + } + + @Test + fun `home ranking fragment opens profile only for touchable creator`() { + val source = homeMainFragmentSource() + + assertTrue(source.contains("private fun openCreatorRankingProfile(item: CreatorRankingItem)")) + assertTrue(source.contains("if (item.creatorId <= 0L) return")) + assertTrue(source.contains("openCreatorProfile(item.creatorId)")) + assertFalse(source.contains("EXTRA_USER_ID, 0L")) + } + @Test fun `home main fragment phase9 replaces sample content with viewmodel state binding`() { val source = homeMainFragmentSource() @@ -1321,6 +1410,14 @@ class HomeMainFragmentLayoutTest { ).readText() } + private fun homeMainLayoutSource(): String { + val projectRoot = java.io.File("..").canonicalFile + return java.io.File( + projectRoot, + "app/src/main/res/layout/fragment_v2_main_home.xml" + ).readText() + } + private fun homeRecommendationViewModelSource(): String { val projectRoot = java.io.File("..").canonicalFile return java.io.File(