diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt index c637bc52..af6eadbc 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt @@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding +import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType +import kr.co.vividnext.sodalive.v2.main.content.model.AudioRankingsUiState import kr.co.vividnext.sodalive.v2.main.content.model.AudioRecommendationsUiState import kr.co.vividnext.sodalive.v2.main.content.model.ContentAudioCardSection import kr.co.vividnext.sodalive.v2.main.content.model.ContentAudioCardUiModel @@ -34,12 +36,15 @@ import kr.co.vividnext.sodalive.v2.main.content.ui.ContentOriginalSeriesAdapter import kr.co.vividnext.sodalive.v2.main.content.ui.addContentGridItemSpacing import kr.co.vividnext.sodalive.v2.main.content.ui.addContentHorizontalItemSpacing import kr.co.vividnext.sodalive.v2.widget.AudioContentCardSize +import kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingAdapter +import kr.co.vividnext.sodalive.v2.widget.contentranking.ContentRankingItem import org.koin.androidx.viewmodel.ext.android.viewModel class ContentMainFragment : BaseFragment( FragmentV2MainContentBinding::inflate ) { private val contentMainViewModel: ContentMainViewModel by viewModel() + private val contentRankingViewModel: ContentRankingViewModel by viewModel() private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) } private val originalSeriesAdapter = ContentOriginalSeriesAdapter { openSeriesDetail(it) } private val latestAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) } @@ -48,20 +53,77 @@ class ContentMainFragment : BaseFragment( private val pointAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) } private val commentedAudioAdapter = ContentCommentedAudioAdapter { openAudioContentDetail(it) } private val recommendedAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Large) { openAudioContentDetail(it) } + private val contentRankingAdapter = ContentRankingAdapter { openRankingAudioContentDetail(it) } private var bannerBinder: ContentBannerBinder? = null + private var isRecommendationLoading = false + private var isRankingLoading = false + private var hasSelectedRankingTab = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.textTabBarContent.root.setMenus( - listOf(getString(R.string.screen_content_tab_recommendation)), - selectedIndex = 0 - ) + setUpTextTabs() + setUpRankingTypeTabs() + showContentTab(CONTENT_TAB_RECOMMENDATION) setUpSectionTitles() setUpAdapters() bindObservers() contentMainViewModel.loadRecommendations() } + private fun setUpTextTabs() { + binding.textTabBarContent.root.setMenus( + listOf( + getString(R.string.screen_content_tab_recommendation), + getString(R.string.screen_content_tab_ranking), + getString(R.string.screen_content_tab_all) + ), + selectedIndex = 0 + ) + binding.textTabBarContent.root.setOnTabSelectedListener { index -> + showContentTab(index) + } + } + + private fun setUpRankingTypeTabs() { + binding.viewContentRankingTypeTabs.root.setMenus( + AudioRankingType.entries.map { type -> getString(type.labelResId()) }, + selectedIndex = AudioRankingType.WEEKLY_POPULAR.ordinal + ) + binding.viewContentRankingTypeTabs.root.setOnTabSelectedListener { index -> + contentRankingViewModel.loadRankings(AudioRankingType.entries[index]) + } + } + + private fun showContentTab(index: Int) { + when (index) { + CONTENT_TAB_RECOMMENDATION -> showRecommendationContent() + CONTENT_TAB_RANKING -> showRankingContent() + CONTENT_TAB_ALL -> hideContentSurfaces() + } + } + + private fun showRecommendationContent() { + binding.nsvContentRecommendationContent.visibility = View.VISIBLE + binding.viewContentRankingTypeTabs.root.visibility = View.GONE + binding.rvContentRankings.visibility = View.GONE + } + + private fun showRankingContent() { + binding.nsvContentRecommendationContent.visibility = View.GONE + binding.viewContentRankingTypeTabs.root.visibility = View.VISIBLE + binding.rvContentRankings.visibility = View.VISIBLE + if (!hasSelectedRankingTab) { + hasSelectedRankingTab = true + contentRankingViewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR) + } + } + + private fun hideContentSurfaces() { + binding.nsvContentRecommendationContent.visibility = View.GONE + binding.viewContentRankingTypeTabs.root.visibility = View.GONE + binding.rvContentRankings.visibility = View.GONE + } + private fun setUpAdapters() { bannerBinder = ContentBannerBinder(binding.rvContentBanners).apply { setOnBannerClick { onBannerClick(it) } @@ -101,6 +163,10 @@ class ContentMainFragment : BaseFragment( adapter = recommendedAudioAdapter addContentGridItemSpacing() } + binding.rvContentRankings.apply { + layoutManager = ContentRankingAdapter.createGridLayoutManager(requireContext()) + adapter = contentRankingAdapter + } } private fun bindObservers() { @@ -114,15 +180,28 @@ class ContentMainFragment : BaseFragment( } } contentMainViewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> - if (isLoading) { - loadingDialog.show(screenWidth) - } else { - loadingDialog.dismiss() - } + isRecommendationLoading = isLoading + updateLoadingDialog() } contentMainViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> toastMessage?.let(::showToast) } + contentRankingViewModel.rankingStateLiveData.observe(viewLifecycleOwner) { state -> + when (state) { + is AudioRankingsUiState.Content -> contentRankingAdapter.submitItems(state.items) + is AudioRankingsUiState.Empty, + is AudioRankingsUiState.Error -> contentRankingAdapter.submitItems(emptyList()) + + AudioRankingsUiState.Loading -> Unit + } + } + contentRankingViewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + isRankingLoading = isLoading + updateLoadingDialog() + } + contentRankingViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> + toastMessage?.let(::showToast) + } } private fun bindContent(content: AudioRecommendationsUiState.Content) { @@ -213,6 +292,11 @@ class ContentMainFragment : BaseFragment( ) } + private fun openRankingAudioContentDetail(item: ContentRankingItem) { + val audioContentId = item.contentId.toLongOrNull() ?: return + openAudioContentDetail(audioContentId) + } + private fun openSeriesDetail(item: ContentOriginalSeriesUiModel) { val seriesId = item.seriesId.takeIf { it > 0L } ?: return startActivity( @@ -227,6 +311,14 @@ class ContentMainFragment : BaseFragment( ?: toastMessage.resId?.let { resId -> showToast(getString(resId)) } } + private fun updateLoadingDialog() { + if (isRecommendationLoading || isRankingLoading) { + loadingDialog.show(screenWidth) + } else { + loadingDialog.dismiss() + } + } + private fun emptyContent(): AudioRecommendationsUiState.Content { return AudioRecommendationsUiState.Content( banners = ContentBannerSection(emptyList()), @@ -241,4 +333,19 @@ class ContentMainFragment : BaseFragment( } private fun List<*>.toSectionVisibility(): Int = if (isEmpty()) View.GONE else View.VISIBLE + + private fun AudioRankingType.labelResId(): Int = when (this) { + AudioRankingType.WEEKLY_POPULAR -> R.string.screen_content_ranking_type_weekly_popular + AudioRankingType.RISING -> R.string.screen_content_ranking_type_rising + AudioRankingType.REVENUE -> R.string.screen_content_ranking_type_revenue + AudioRankingType.SALES_COUNT -> R.string.screen_content_ranking_type_sales_count + AudioRankingType.COMMENT_COUNT -> R.string.screen_content_ranking_type_comment_count + AudioRankingType.LIKE_COUNT -> R.string.screen_content_ranking_type_like_count + } + + companion object { + private const val CONTENT_TAB_RECOMMENDATION = 0 + private const val CONTENT_TAB_RANKING = 1 + private const val CONTENT_TAB_ALL = 2 + } } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt index 14e3f93b..773f40c6 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt @@ -33,18 +33,44 @@ class ContentMainFragmentSourceTest { ).readText() assertTrue(source.contains("private val contentMainViewModel: ContentMainViewModel by viewModel()")) + assertTrue(source.contains("private val contentRankingViewModel: ContentRankingViewModel by viewModel()")) assertTrue(source.contains("ContentBannerBinder(binding.rvContentBanners)")) assertTrue(source.contains("ContentOriginalSeriesAdapter")) assertTrue(source.contains("ContentAudioCardAdapter")) assertTrue(source.contains("ContentNewAndHotAdapter")) assertTrue(source.contains("ContentCommentedAudioAdapter")) + assertTrue(source.contains("ContentRankingAdapter")) + assertTrue(source.contains("ContentRankingAdapter.createGridLayoutManager(requireContext())")) assertTrue(source.contains("recommendationsStateLiveData.observe(viewLifecycleOwner)")) + assertTrue(source.contains("rankingStateLiveData.observe(viewLifecycleOwner)")) assertTrue(source.contains("contentMainViewModel.loadRecommendations()")) + assertTrue(source.contains("contentRankingViewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)")) assertTrue(source.contains("LoadingDialog(requireActivity(), layoutInflater)")) assertTrue(source.contains("loadingDialog.show(screenWidth)")) assertTrue(source.contains("toastMessage?.let(::showToast)")) } + @Test + fun `content 랭킹 layout과 tab source는 Phase 5 요구를 포함한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt" + ).readText() + val fragmentLayout = projectFile("app/src/main/res/layout/fragment_v2_main_content.xml").readText() + + assertTrue(fragmentLayout.contains("@+id/view_content_ranking_type_tabs")) + assertTrue(fragmentLayout.contains("layout=\"@layout/view_capsule_tab_bar\"")) + assertTrue(fragmentLayout.contains("@+id/rv_content_rankings")) + assertTrue(source.contains("R.string.screen_content_tab_recommendation")) + assertTrue(source.contains("R.string.screen_content_tab_ranking")) + assertTrue(source.contains("R.string.screen_content_tab_all")) + assertTrue(source.contains("binding.textTabBarContent.root.setOnTabSelectedListener")) + assertTrue(source.contains("binding.viewContentRankingTypeTabs.root.setMenus")) + assertTrue(source.contains("binding.viewContentRankingTypeTabs.root.setOnTabSelectedListener")) + assertTrue(source.contains("AudioRankingType.entries[index]")) + assertTrue(source.contains("screen_content_ranking_type_weekly_popular")) + assertTrue(source.contains("screen_content_ranking_type_like_count")) + } + @Test fun `content 추천 source는 오디오와 시리즈 routing extra를 사용한다`() { val source = projectFile(